<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel><title>Homelab the Right Way</title><link>https://chartmann1590.github.io/homelab-the-right-way/</link><atom:link href="https://chartmann1590.github.io/homelab-the-right-way/feed.xml" rel="self" type="application/rss+xml"/>
<description>Practical homelab articles on Proxmox, OPNsense, Home Assistant, backups and DIY builds, based on the Homelab the Right Way ebooks. A new post every day.</description><language>en-us</language><lastBuildDate>Thu, 15 Oct 2026 12:30:00 -0400</lastBuildDate>
<image><url>https://chartmann1590.github.io/homelab-the-right-way/assets/img/avatar.png</url><title>Homelab the Right Way</title><link>https://chartmann1590.github.io/homelab-the-right-way/</link></image>
<item><title>Three new Homelab the Right Way ebooks: monitoring, containers and automation</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/three-new-homelab-ebooks-monitoring-containers-automation/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/three-new-homelab-ebooks-monitoring-containers-automation/</guid>
<pubDate>Thu, 15 Oct 2026 12:30:00 -0400</pubDate><description>Homelab the Right Way Books 7, 8 and 9 are out: Watch Your Homelab (monitoring and alerts), Containers Without the Chaos (Docker Compose, Caddy, HTTPS and single sign-on) and Automate Your Homelab (Git, cloud-init, OpenTofu and Ansible). Each has a free sample chapter.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og.jpg" type="image/jpeg" length="0"/>
<category>homelab</category><category>monitoring</category><category>docker</category><category>ansible</category><category>ebook</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og.jpg" alt=""></p><p>The homelab series grows from six books to nine today. The three new ones cover the parts that turn a pile of machines into something you can trust: knowing when it breaks, running apps without the mess, and being able to rebuild the whole thing from a repository. They are written the same way as the first six: short chapters, real configs and commands, diagrams instead of walls of text, a checklist and a cheat sheet at the end. Every version number was checked against the project's own releases page or documentation in October 2026, and the books say where.</p>
<p>Each book is a PDF and EPUB, $19, and each one has a free sample: the cover, the full table of contents and one complete chapter.</p>
<h2 id="which-one-to-start-with">Which one to start with</h2>
<ul><li>You find out something broke when a person notices: <strong>Book 7</strong>, Watch Your Homelab.</li><li>Your Docker host is a folder of half-remembered <code>docker run</code> commands: <strong>Book 8</strong>, Containers Without the Chaos.</li><li>You could not rebuild your lab from scratch without a weekend of clicking: <strong>Book 9</strong>, Automate Your Homelab.</li></ul>
<h2 id="book-7-watch-your-homelab">Book 7: Watch Your Homelab</h2>
<p><em>Monitoring, Alerts and Logs for Proxmox, Docker and Home Assistant with Prometheus, Grafana, Uptime Kuma and ntfy</em></p>
<p>The monitoring stack that tells you first, and only when it matters. It answers three questions for every machine you run (is it up, is it healthy, what happened?) with free, self-hosted tools: Uptime Kuma checks from the outside, Prometheus with node_exporter and cAdvisor for metrics, the Proxmox and Home Assistant exporters, Grafana dashboards that each answer one question, alert rules routed to your phone through ntfy, and logs in Loki shipped by Grafana Alloy (Promtail reached end of life on March 2, 2026).</p>
<p>Inside: Uptime Kuma monitors, retries, push heartbeats for backups and cron jobs, and a status page; self-hosted ntfy with access tokens and a priority ladder; a Prometheus stack with both retention limits set; Proxmox metrics through prometheus-pve-exporter with a read-only PVEAuditor token; SMART disk health and UPS status through NUT; a disk-fill ETA panel with predict_linear; alert rules with <code>for:</code> durations and Alertmanager routing; a runbook template and a monthly review.</p>
<p>58 pages, 25 diagrams. The free sample is all of Chapter 2, "Is It Up? Uptime Kuma".</p>
<p><a href="https://payhip.com/b/An62R">Get the book</a> · <a href="https://payhip.com/b/cIY8z">Read the free sample</a></p>
<h2 id="book-8-containers-without-the-chaos">Book 8: Containers Without the Chaos</h2>
<p><em>Docker Compose, Reverse Proxies, Automatic HTTPS and Single Sign-On for Self-Hosters</em></p>
<p>Self-hosted apps on Docker without the mess: one Docker host in a Proxmox VM, one folder per stack, one reverse proxy as the only front door, real trusted HTTPS for internal names without opening a single port, one good login in front of every app, updates you choose instead of surprises, and backups you have actually restored.</p>
<p>Inside: where Docker should live on Proxmox and what the Proxmox docs recommend; the official Docker Engine install on Debian with log rotation set before the first container; why published ports skip ufw; every part of a compose.yaml you will use, plus a complete copy-ready stack; a tidy /srv/stacks layout with one shared proxy network; Caddy in front of everything with a DNS-01 wildcard certificate and split DNS at home; Authelia single sign-on with forward auth and two-factor; Compose secrets, pinned tags and Diun update notifications (Watchtower's repository was archived in December 2025); two-layer backups; and a troubleshooting routine that starts with evidence instead of deleting things.</p>
<p>63 pages, 22 diagrams. The free sample is all of Chapter 3, "Anatomy of a compose.yaml".</p>
<p><a href="https://payhip.com/b/Lmhcb">Get the book</a> · <a href="https://payhip.com/b/1qoiW">Read the free sample</a></p>
<h2 id="book-9-automate-your-homelab">Book 9: Automate Your Homelab</h2>
<p><em>Ansible, Cloud-Init, OpenTofu and Git for a Proxmox Lab You Can Rebuild in an Afternoon</em></p>
<p>A lab you can rebuild: the definitions live in Git, the secrets live in a vault, the data lives in your backups, and a timed rebuild drill proves the three fit together.</p>
<p>Inside: a self-hosted Forgejo repository with a push mirror and a repo layout that scales from three VMs to thirty; the Debian 13 cloud image turned into a Proxmox template with <code>qm</code>, SSH keys and a vendor snippet, and full clones that personalize themselves on first boot; VMs and containers declared with OpenTofu and the bpg/proxmox provider behind a least-privilege API token; Ansible inventory, ansible.cfg, idempotency, <code>--check</code> and <code>--diff</code>; a baseline role for every Debian box (users, SSH hardening with a drop-in, unattended-upgrades, time sync); Docker hosts and compose stacks deployed from the repository with secrets in ansible-vault; what is realistic for OPNsense and Home Assistant (scheduled config backups into Git and a documented restore path); and CI on Forgejo Actions with ansible-lint and tofu validate on every push, drift checks and the rebuild drill step by step.</p>
<p>58 pages, 22 diagrams. The free sample is all of Chapter 3, "Golden Images with Cloud-Init".</p>
<p><a href="https://payhip.com/b/ZcsXi">Get the book</a> · <a href="https://payhip.com/b/yql34">Read the free sample</a></p>
<h2 id="how-they-fit-with-the-first-six">How they fit with the first six</h2>
<p>Book 1 sets up Proxmox, OPNsense and Home Assistant; Book 3 is the backup playbook; Book 5 locks down the network; Book 6 makes Home Assistant automations reliable. The new three assume those exist and build on them: Book 7 watches all of it, Book 8 is where your self-hosted apps live, and Book 9 is how the whole lab gets written down and rebuilt. Each book stands on its own, with cross-references where another book goes deeper.</p>
<h2 id="bundles">Bundles</h2>
<p>All nine homelab books are in the Homelab Bundle ($59), and every book in the store, the Android series included, is in the Everything Bundle ($89). The code RIGHTWAY20 takes 20% off any order through October 31.</p><p><a href="https://payhip.com/b/Lmhcb">Go deeper: Containers Without the Chaos</a></p>]]></content:encoded></item><item><title>Self-host your photos, files and passwords the safe way</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/self-host-photos-files-passwords-safe-way/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/self-host-photos-files-passwords-safe-way/</guid>
<pubDate>Thu, 15 Oct 2026 12:30:00 -0400</pubDate><description>Immich, Nextcloud and Vaultwarden on one small box at home, without the six classic traps: one disk for everything, a database on a network share, open signups, forwarded ports, a backup that isn&#x27;t one, and updates that are never or always.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-21.jpg" type="image/jpeg" length="0"/>
<category>self-hosting</category><category>immich</category><category>nextcloud</category><category>vaultwarden</category><category>tailscale</category><category>proxmox</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-21.jpg" alt=""></p><p>Your photos, your files and your passwords, running on a small box in your house. That is the whole promise of self-hosting your life: Immich instead of Google Photos, Nextcloud (or Syncthing) instead of Drive, Vaultwarden instead of a cloud password manager.</p>
<p>None of the individual installs is hard. What goes wrong is the layout around them. Each stage of the build has one classic trap, and each trap has a short, documented fix. Here is the path end to end.</p>
<h2 id="trap-1-one-disk-one-bad-day">Trap 1: one disk, one bad day</h2>
<p>The most common beginner layout is a single SSD holding Proxmox, the containers and every photo you own. When that SSD wears out or fills up, you lose the server and the data at the same moment, and the restore needs the very disk that died.</p>
<p><strong>The fix:</strong> separate the system from the data. A small SSD (256 to 512 GB) holds Proxmox and the guests' virtual disks, databases included. A second, large drive holds the photo library, the Nextcloud files and backups waiting to go elsewhere.</p>
<p>Then run all three apps in one Debian 13 VM as a Docker host. Immich's documentation recommends a full VM and calls Docker inside an LXC container not recommended, possible only for advanced users. Give the VM at least 8 GB of RAM (12 to 16 GB is comfortable) and set its CPU type to <code>host</code> or <code>x86-64-v2-AES</code>, because Immich's machine-learning container needs the x86-64-v2 instruction level.</p>
<p>Inside the VM, mount the data disk by its label, with <code>nofail</code>:</p>
<pre><code>mkfs.ext4 -L data /dev/sdb
mkdir -p /srv/data
echo "LABEL=data /srv/data ext4 defaults,nofail 0 2" &gt;&gt; /etc/fstab
mount -a &amp;&amp; df -h /srv/data</code></pre>
<p>The label means a reordered disk never mounts in the wrong place, and <code>nofail</code> means a missing disk still lets the VM boot so you can fix it over SSH.</p>
<h2 id="trap-2-the-immich-database-on-a-network-share">Trap 2: the Immich database on a network share</h2>
<p>Immich keeps your files on disk and everything else (who is in the photo, which album it belongs to, where the file lives) in Postgres. The documentation is blunt: the database belongs on local SSD storage, never on a network share of any kind. Point <code>DB_DATA_LOCATION</code> at the NAS and a network hiccup during a write can leave Postgres corrupted and the timeline empty.</p>
<p>The second Immich trap is the version: the server only talks to mobile apps of a compatible major version, so running <code>latest</code> with auto-updates can silently stop your phone backups.</p>
<p><strong>The fix</strong> is a handful of lines in <code>.env</code>:</p>
<pre><code>UPLOAD_LOCATION=/srv/data/immich
DB_DATA_LOCATION=/opt/immich/postgres
IMMICH_VERSION=v3
DB_PASSWORD=ChangeMeToALongRandomString</code></pre>
<p>Library on the data disk, database on the SSD, pinned to the major version. Use only letters and digits in the password; the docs warn that special characters confuse Docker's parsing. Turn on the storage template (Administration → Settings → Storage Template) before the first upload, so the library on disk is a readable year/month/day tree.</p>
<h2 id="trap-3-the-hand-built-nextcloud">Trap 3: the hand-built Nextcloud</h2>
<p>Web server, PHP, database, Redis, cron jobs and a dozen tuning settings, all by hand. Each piece is easy; together they are a full-time hobby, and months later the admin page is a wall of warnings.</p>
<p><strong>The fix:</strong> first decide honestly what you need. A folder that stays the same on your laptop and phone is a Syncthing job. Documents, family sharing, calendars and contacts is a Nextcloud job, and then Nextcloud All-in-One (AIO) is the way: the official Docker setup that runs and updates the whole stack for you. Set <code>NEXTCLOUD_DATADIR=/srv/data/nextcloud</code> in the start command before the first start, because it can't be changed later without a fiddly migration. And don't rename the <code>nextcloud-aio-mastercontainer</code> container; AIO's self-updates depend on that exact name.</p>
<h2 id="trap-4-open-signups-and-plain-http-on-vaultwarden">Trap 4: open signups and plain HTTP on Vaultwarden</h2>
<p>Leave registration open and anyone who finds your URL can create an account; bots scan for exactly that. Serve it over plain HTTP and the web vault fails, because it relies on browser crypto APIs that most browsers only allow over HTTPS.</p>
<p><strong>The fix</strong> is in the compose file:</p>
<pre><code>SIGNUPS_ALLOWED: "false"
INVITATIONS_ALLOWED: "true"
SHOW_PASSWORD_HINT: "false"
ports:
  - "127.0.0.1:8081:80"</code></pre>
<p>Generate the admin token as an Argon2 hash with <code>docker run --rm -it vaultwarden/server /vaultwarden hash</code>, paste it in, and double every one of its five dollar signs, or Docker Compose silently breaks it. Then <code>chmod 600 /opt/vaultwarden/compose.yaml</code>, turn on two-step login, and print an emergency sheet with your vault URL, email and master passphrase.</p>
<h2 id="trap-5-forwarding-ports-to-everything">Trap 5: forwarding ports to everything</h2>
<p>Forward port 443 on the router to the server and every service is reachable from the whole internet; within hours your logs fill with bots trying <code>/admin</code> and <code>/wp-login.php</code>.</p>
<p><strong>The fix:</strong> Tailscale on the Docker VM as a subnet router, so your phone reaches home with no open router ports:</p>
<pre><code>tailscale up --advertise-routes=192.168.10.0/24</code></pre>
<p>Approve the route in the admin console and disable key expiry for the server. For real HTTPS names, use a reverse proxy with a wildcard certificate from the DNS challenge, with <code>*.example.com</code> pointing at the proxy's private address. If family members need a shared album link, forward only 443, only to the proxy, and never expose Vaultwarden or the AIO admin interface.</p>
<h2 id="trap-6-the-backup-that-isn-t-one-and-updates-that-are-never-or-always">Trap 6: the backup that isn't one, and updates that are never or always</h2>
<p>A file copy of a running database can restore as garbage. For Vaultwarden, use its built-in online backup in a nightly script:</p>
<pre><code>docker exec vaultwarden /vaultwarden backup
tar -czf /srv/data/backups/vw-$(date +%F).tgz \
  -C /opt/vaultwarden data compose.yaml</code></pre>
<p>Put the whole Docker VM on Proxmox Backup Server with a nightly snapshot-mode job, add an off-site copy, and test a restore once a quarter. On the bad day, restore passwords first: Vaultwarden comes back from the newest <code>vw-*.tgz</code> on any Linux machine with Docker, and it unlocks everything else.</p>
<p>For updates, never updating leaves known holes open, and auto-pulling every new image lands breaking releases at 3 AM. Pick a monthly maintenance hour and snapshot before touching anything:</p>
<pre><code>qm snapshot 201 pre-update-$(date +%Y%m%d)
cd /opt/immich
docker compose pull &amp;&amp; docker compose up -d</code></pre>
<p>On the Proxmox host itself, use <code>apt full-upgrade</code>, never plain <code>apt upgrade</code>. Then test each app, and delete the snapshot so it doesn't slowly fill thin storage.</p>
<h2 id="checklist">Checklist</h2>
<ul><li>System and guest disks on an SSD; photos and files on a separate data drive mounted by label with <code>nofail</code></li><li>One Debian 13 VM as the Docker host, CPU type <code>host</code> or <code>x86-64-v2-AES</code></li><li>Immich: <code>DB_DATA_LOCATION</code> on local SSD, <code>IMMICH_VERSION=v3</code>, storage template on before the first upload</li><li>Nextcloud via AIO with <code>NEXTCLOUD_DATADIR</code> set before the first start, or Syncthing with file versioning on</li><li>Vaultwarden: signups off, HTTPS only, Argon2 admin token with doubled dollar signs, two-step login, emergency sheet printed</li><li>Tailscale subnet router, key expiry off; no ports forwarded except 443 to the proxy if you must share</li><li>Nightly PBS job, Vaultwarden online backup in cron, an off-site copy, a dated quarterly restore test</li><li>A monthly update hour that starts with a snapshot</li></ul><p><a href="https://payhip.com/b/Ut72a">Go deeper: Self-Host Your Life</a></p>]]></content:encoded></item><item><title>Pawdub: your dog finally has subtitles (and yes, it&#x27;s a joke app that really listens)</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/pawdub-funny-pet-translator-android-app/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/pawdub-funny-pet-translator-android-app/</guid>
<pubDate>Thu, 15 Oct 2026 12:30:00 -0400</pubDate><description>Pawdub puts funny movie subtitles on your dog or cat when it actually barks or meows. Real on-device sound and scene detection, 8 personas, 59 languages, no account and nothing uploaded. It is a joke app and says so. Coming soon to Google Play.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.pawdub.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>pets</category><category>on-device-ai</category><category>fun</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.pawdub.jpg" alt=""></p><p>Your dog barks at the front door. A subtitle fades in under him: "THE MAILMAN HAS RETURNED. I WILL HANDLE THIS." That is the whole idea behind Pawdub, a new Android app from Hartmann Studios. It is for fun, and it says so on the first screen: no app can translate animals, and Pawdub never claims to. What it does do is listen and look for real, then pick a line that fits.</p>
<h2 id="what-actually-happens">What actually happens</h2>
<p>Point the camera at your dog or cat. The app listens for a bark, whine, growl, howl, meow, purr or hiss, detected on your phone. When it hears one, a movie-style subtitle appears. No sound, no subtitle. It also looks at the scene (a door, a food bowl, the couch, a toy, a bed), so the joke matches the moment: a cat at the window gets a different line than a cat at an empty bowl.</p>
<p>The lines themselves are jokes written for each persona. They are not a reading of what your pet means, and the app gives no advice about your pet's health, mood, training or behaviour. If you are worried about your pet, talk to a vet.</p>
<h2 id="personas">Personas</h2>
<p>Eight of them: Drama Queen, Golden Retriever Energy, Grumpy Old Man, Royal Highness, Tiny Gremlin, Food Critic, Conspiracy Theorist and Noir Detective. Each one has its own voice and its own set of lines, so the same bark becomes a very different movie depending on who is "talking".</p>
<h2 id="59-languages-offline">59 languages, offline</h2>
<p>Subtitles come in English, Spanish, Portuguese, French, German, Japanese, Korean, Hindi, Arabic and 50 more. Each language is downloaded from Google once and then works offline. Pro adds bilingual subtitles.</p>
<h2 id="clips-you-already-have">Clips you already have</h2>
<p>Open a video from your gallery and Pawdub adds subtitles to the moments where your pet makes a sound. Styles: Cinema and Meme are free; Pro adds Bubble (a speech bubble that follows your pet) and Text message.</p>
<h2 id="the-rest">The rest</h2>
<ul><li>Pet voices: hear the line read aloud with your phone's text-to-speech.</li><li>Pet profiles for every dog and cat in the house.</li><li>Bark diary: a log of what your pet "said" today.</li><li>Stickers and poster templates made from your clips.</li><li>Export to your gallery and share anywhere.</li></ul>
<h2 id="free-and-pro">Free and Pro</h2>
<p>Free: unlimited live dubbing, a few exports a day with a small Pawdub tag, 3 personas, your language plus one more, the Cinema and Meme styles, and a starter sticker pack. Ads appear in the free version, never on the camera screen.</p>
<p>Pro: every persona, all languages, every style, all voices, longer gallery clips, write your own lines, unlimited exports without the tag, and no ads. It is sold as a subscription with a free trial for new subscribers, or as a one-time lifetime unlock; prices are shown in Google Play.</p>
<h2 id="privacy">Privacy</h2>
<p>Everything runs on your phone with on-device AI. The microphone and camera are used only while the camera screen is open, audio and video are processed on the device and never uploaded, and there is no account. Delete a clip from the library, or everything from Settings &gt; Delete all data. The privacy policy is at <a href="https://chartmann1590.github.io/pawdub-site/privacy/">chartmann1590.github.io/pawdub-site/privacy</a>.</p>
<p>Pawdub has been submitted to Google Play and is in review. Until the listing is live, the <a href="https://chartmann1590.github.io/pawdub-site/">Pawdub site</a> has the screenshots and the promo video; this post gets the Play link the day it goes live.</p><p><a href="https://chartmann1590.github.io/pawdub-site/">Pawdub: Funny Pet Translator: coming soon to Google Play</a></p>]]></content:encoded></item><item><title>Upgrading to AGP 9: the Kotlin plugin you can delete</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/agp-9-kotlin-plugin-you-can-delete/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/agp-9-kotlin-plugin-you-can-delete/</guid>
<pubDate>Thu, 15 Oct 2026 12:30:00 -0400</pubDate><description>Since Android Gradle plugin 9.0, Kotlin is built into AGP. Here is what to remove, what to move, why kapt has to go, and the one icons dependency Material 3 stopped adding for you.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>kotlin</category><category>jetpack compose</category><category>gradle</category><category>agp</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og.jpg" alt=""></p><p>You open an older Android project, bump the Android Gradle plugin to 9, and sync. The build stops with one of two messages: "Cannot add extension with name 'kotlin'" or "The 'org.jetbrains.kotlin.android' plugin is no longer required". Nothing in your code changed. The build files did not keep up with AGP.</p>
<p>This post covers what changed in AGP 9, the small set of edits that fix it, and two related surprises: kapt and the Material icons. Everything here comes from Chapter 2 (Thinking in Composables) and Chapter 8 (Saving Data: Room and DataStore) of <em>Compose Without the Confusion</em>.</p>
<h2 id="the-trap-applying-kotlin-twice">The trap: applying Kotlin twice</h2>
<p>Since Android Gradle plugin 9.0, Kotlin support is built into AGP itself. You no longer apply the separate <code>org.jetbrains.kotlin.android</code> plugin. Older projects apply it anyway, because that was the rule for years. With AGP 9 the plugin and AGP both try to set up Kotlin, and the sync fails with the messages above.</p>
<p>There is a second leftover in most older modules: an <code>android { kotlinOptions { } }</code> block. That block belonged to the old plugin, so it has to move too.</p>
<h2 id="the-fix-three-edits">The fix: three edits</h2>
<p>Google documents the migration for built-in Kotlin. In short:</p>
<ol><li>Remove the kotlin-android plugin from your build files.</li><li>Remove it from your version catalog as well, so nothing re-adds it.</li><li>Move any <code>android { kotlinOptions { } }</code> block to <code>kotlin { compilerOptions { } }</code>.</li></ol>
<p>One plugin stays. You still apply the <strong>Compose compiler plugin</strong>. It ships with Kotlin and uses the same version number, so it points at the <code>kotlin</code> version in your catalog.</p>
<h3 id="what-a-working-agp-9-setup-looks-like">What a working AGP 9 setup looks like</h3>
<p>Here is the version catalog from the book's sample project, using the versions current at the time of writing (October 2026):</p>
<pre><code># gradle/libs.versions.toml (excerpt)
[versions]
agp = "9.4.0"
kotlin = "2.4.21"
ksp = "2.3.12"
composeBom = "2026.09.00"
activity = "1.13.0"
lifecycle = "2.11.0"

[libraries]
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-icons-core = { module = "androidx.compose.material:material-icons-core" }

[libraries.activity-compose]
module = "androidx.activity:activity-compose"
version.ref = "activity"

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }</code></pre>
<p>Notice what is missing from <code>[plugins]</code>: there is no <code>kotlin-android</code> entry. The only Kotlin-related plugin is <code>kotlin-compose</code>, and its version is the Kotlin version.</p>
<p>The module build file is just as short:</p>
<pre><code>// app/build.gradle.kts (excerpt)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "com.example.pantry"
    compileSdk = 36   // use the newest stable API level your AGP supports
    defaultConfig {
        applicationId = "com.example.pantry"
        minSdk = 24
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"
    }
    buildFeatures { compose = true }
}</code></pre>
<p>Two plugins. No <code>kotlinOptions</code> inside <code>android { }</code>. If your module still has compiler flags, they go in a top-level <code>kotlin { compilerOptions { } }</code> block instead.</p>
<h2 id="the-second-trap-kapt">The second trap: kapt</h2>
<p>If your project uses Room, Hilt or another annotation processor through kapt, the plugin cleanup is not the end. With AGP 9's built-in Kotlin, the old <code>kotlin-kapt</code> plugin is not compatible. Google's migration guide recommends moving to <strong>KSP</strong>, and offers <code>com.android.legacy-kapt</code> only as a stopgap. Room, Hilt and most annotation processors support KSP today.</p>
<p>Moving Room to KSP is a few lines. The catalog gets the KSP and Room plugins (the <code>ksp</code> version is already in <code>[versions]</code> above):</p>
<pre><code># gradle/libs.versions.toml (additions)
[versions]
room = "2.8.5"

[libraries]
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }

[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
room = { id = "androidx.room", version.ref = "room" }</code></pre>
<p>And the module applies them, swapping <code>kapt(...)</code> for <code>ksp(...)</code> on the compiler:</p>
<pre><code>// app/build.gradle.kts
plugins {
    // ...
    alias(libs.plugins.ksp)
    alias(libs.plugins.room)
}

room {
    schemaDirectory("$projectDir/schemas")
}

dependencies {
    implementation(libs.room.runtime)
    implementation(libs.room.ktx)
    ksp(libs.room.compiler)
}</code></pre>
<p>The Room Gradle plugin's <code>schemaDirectory</code> writes a JSON description of every database version into your project. Commit those files. Room uses them for automatic migrations and for migration tests.</p>
<p>Hilt works the same way. It is built on Dagger and processed with KSP. At the time of writing the Dagger and Hilt release is 2.60.1.</p>
<h2 id="the-third-trap-icons-that-stop-resolving">The third trap: icons that stop resolving</h2>
<p>This one is not AGP, but it tends to arrive in the same upgrade. Since Material 3 1.4.0, the library no longer pulls in <code>material-icons-core</code> for you. Its release notes say so. After the upgrade, <code>Icons.Default.Add</code> suddenly does not resolve, and the error looks unrelated to anything you touched.</p>
<p>The fix is to add the artifact yourself. It is already in the catalog above as <code>compose-icons-core</code>, and the BOM still manages its version:</p>
<pre><code>dependencies {
    implementation(platform(libs.compose.bom))
    implementation(libs.compose.ui)
    implementation(libs.compose.material3)
    implementation(libs.compose.icons.core)   // Icons.Default.*
    implementation(libs.compose.ui.tooling.preview)
    implementation(libs.activity.compose)
    debugImplementation(libs.compose.ui.tooling)
}</code></pre>
<p>The BOM line is the one that saves you the most pain. You declare one BOM version and leave the version off every individual Compose library. The BOM picks a set of versions that were tested together.</p>
<h2 id="checklist-for-an-agp-9-upgrade">Checklist for an AGP 9 upgrade</h2>
<ul><li>Remove <code>org.jetbrains.kotlin.android</code> from every module and from the version catalog.</li><li>Move <code>android { kotlinOptions { } }</code> to <code>kotlin { compilerOptions { } }</code>.</li><li>Keep the Compose compiler plugin (<code>org.jetbrains.kotlin.plugin.compose</code>) at your Kotlin version.</li><li>Replace kapt with KSP. Use <code>com.android.legacy-kapt</code> only as a stopgap.</li><li>For Room, add the Room Gradle plugin, set <code>schemaDirectory</code>, and commit the schema files.</li><li>If <code>Icons.Default</code> stops resolving, add <code>material-icons-core</code> and let the BOM version it.</li><li>Leave versions off individual Compose libraries and let one BOM version pick them.</li></ul><p><a href="https://payhip.com/b/fNO53">Go deeper: Compose Without the Confusion</a></p>]]></content:encoded></item><item><title>MeshTalk: chat and talk over Bluetooth mesh with no signal</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/meshtalk-bluetooth-mesh-chat/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/meshtalk-bluetooth-mesh-chat/</guid>
<pubDate>Thu, 15 Oct 2026 12:30:00 -0400</pubDate><description>MeshTalk connects nearby phones over Bluetooth mesh for text, group chat and walkie-talkie voice. No internet, no accounts, no servers.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.meshtalk.app.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>mesh-networking</category><category>offline</category><category>bluetooth</category><category>privacy</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.meshtalk.app.jpg" alt=""></p><p>Every messaging app you use depends on the same thing: a working connection to the internet. On a trail, at a crowded festival, or after a storm knocks out service, that dependency is exactly what fails. MeshTalk works without it. It uses Bluetooth mesh networking to connect phones directly to each other, with no internet, no cellular service, and no router or hotspot.</p>
<h2 id="how-the-mesh-works">How the mesh works</h2>
<p>In a mesh, each phone is both a client and a relay. MeshTalk connects to nearby devices automatically, and messages hop from device to device to reach their destination. The listing's point is that this works in areas with no signal at all, because nothing in the chain needs a tower or a server.</p>
<p>The practical consequence is that the more people nearby are running the app, the more paths a message has to travel.</p>
<h2 id="what-it-does">What it does</h2>
<h3 id="text-chat">Text chat</h3>
<ul><li>Send messages to anyone in range.</li><li>Group chats.</li><li>Message history.</li><li>Read receipts.</li></ul>
<h3 id="voice">Voice</h3>
<ul><li>Walkie-talkie style voice.</li><li>Full-duplex audio, so both sides can talk at once.</li><li>Multiple channels.</li><li>Low battery use, according to the listing.</li></ul>
<h3 id="on-device-ai">On-device AI</h3>
<ul><li>AI message suggestions.</li><li>Smart reply generation.</li><li>Translation between languages.</li><li>All AI features run locally on the phone.</li></ul>
<p>The translation feature is a useful addition for an app like this. If you are at an event or travelling with people who speak another language, the translation still works when nobody has signal, because it does not depend on a server.</p>
<h2 id="privacy">Privacy</h2>
<p>MeshTalk has no accounts and no phone numbers. Messages stay on the devices, there is no cloud and there are no servers, and the listing describes it as end-to-end by design. Since there is no central server in the system, there is no central copy of your conversations to leak.</p>
<h2 id="where-it-makes-sense">Where it makes sense</h2>
<p>The listing names four situations:</p>
<ol><li><strong>Hiking, camping and outdoor events</strong>, where coverage is weak or nonexistent.</li><li><strong>Disaster preparedness</strong>, for when normal networks are down.</li><li><strong>Crowds with poor signal</strong>, where the cell network is overloaded even though it technically exists.</li><li><strong>Privacy-conscious users</strong>, who want a messenger with no account and no server.</li></ol>
<p>For the preparedness case, the thing to remember is that you set this up before you need it. Install the app on the phones of the people you would want to reach, and try it once while everything is working.</p>
<h2 id="good-to-know">Good to know</h2>
<ul><li><strong>Price:</strong> free to download, with an optional premium upgrade mentioned in the listing; the Play listing also notes in-app purchases.</li><li><strong>Ads:</strong> the app contains ads.</li><li><strong>Range:</strong> messages reach people in range, extended by hops between other devices running the app.</li><li><strong>Everyone needs it:</strong> the mesh only includes phones running MeshTalk, so the people you want to talk to need it installed too.</li><li><strong>No sign-up:</strong> no account and no phone number are needed.</li></ul>
<h2 id="is-it-for-you">Is it for you?</h2>
<ul><li>You hike, camp or go to events where your phone usually shows no bars.</li><li>You want a way to reach family or a group if networks go down.</li><li>You want walkie-talkie style voice between phones without extra hardware.</li><li>You want a messenger with no account, no phone number and no server.</li></ul><p><a href="https://play.google.com/store/apps/details?id=com.charles.meshtalk.app">Get MeshTalk: Bluetooth Mesh Chat on Google Play</a></p>]]></content:encoded></item><item><title>The two decisions you can&#x27;t undo before your first Google Play upload</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/two-decisions-you-cant-undo-before-first-google-play-upload/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/two-decisions-you-cant-undo-before-first-google-play-upload/</guid>
<pubDate>Wed, 14 Oct 2026 12:30:00 -0400</pubDate><description>Your application ID and your signing setup are the two Android decisions that are expensive to change after the first upload. Here is how to get both right, plus the versionCode rule that catches people later.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>google play</category><category>kotlin</category><category>gradle</category><category>app signing</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og.jpg" alt=""></p><p>Most of an Android project can be changed later. A screen can be redesigned, a library swapped, a name on the store edited. Two things are different. The application ID and the signing setup are decided before your first upload, and changing either one afterwards ranges from painful to impossible.</p>
<p>Both take minutes to get right. Here is what to do, with the code from <em>Ship Your First Android App</em>.</p>
<h2 id="the-trap-com-example-goes-live">The trap: com.example goes live</h2>
<p>Every tutorial starts with <code>com.example.something</code>. It works fine on your phone, so it is easy to forget. Then the first bundle goes up to Play Console and the placeholder becomes the app's permanent identity.</p>
<p>Android's documentation is blunt about this. Once you publish, you should never change the <code>applicationId</code> in <code>app/build.gradle.kts</code>, because if you change it, Google Play treats the upload as a completely different app. Your installs, ratings and reviews stay with the old ID.</p>
<h2 id="the-fix-pick-an-id-you-can-live-with-forever">The fix: pick an ID you can live with forever</h2>
<p>The naming rules from developer.android.com:</p>
<ul><li>At least two segments, separated by dots.</li><li>Each segment starts with a letter.</li><li>Only letters, digits and underscores: <code>[a-zA-Z0-9_]</code>.</li></ul>
<p>The convention is your domain, reversed, plus the app: <code>com.yourname.torchlight</code>. If you have no domain, a reversed form of a name you control works. What matters is that it is unique and yours. Replace <code>com.example</code> before the first upload, while it is still free to change.</p>
<p>One thing that confuses people: <code>namespace</code> and <code>applicationId</code> start out identical, but they are different things. The namespace is only the Kotlin package for the generated <code>R</code> and <code>BuildConfig</code> classes and can be refactored any time. The application ID is the public identity. Changing one does not change the other.</p>
<h2 id="the-second-decision-how-your-app-is-signed">The second decision: how your app is signed</h2>
<p>Every Android app is signed, and devices only install an update signed by the same key as the installed version. Google Play splits this into two keys:</p>
<div class="table"><table><thead><tr><th>Key</th><th>Who holds it</th><th>What it signs</th><th>If you lose it</th></tr></thead><tbody><tr><td>Upload key</td><td>You, in a keystore file (.jks)</td><td>The bundle you upload to Play Console</td><td>Google can reset it; you request a reset</td></tr><tr><td>App signing key</td><td>Google Play</td><td>The APKs delivered to users' devices</td><td>Nothing to lose; Google keeps it</td></tr></tbody></table></div>
<p>Play App Signing is required for new apps, and new apps are automatically enrolled with Google-generated keys. Your job is only the upload key. Play Console asks for an RSA key of 2048 bits or more in a Java keystore, and Android's signing guide recommends a validity of at least 25 years:</p>
<pre><code>keytool -genkeypair -v \
  -keystore upload-keystore.jks \
  -alias upload \
  -keyalg RSA -keysize 2048 \
  -validity 10000</code></pre>
<p><code>-validity</code> is in days. 10,000 days is a little over 27 years.</p>
<h3 id="where-the-key-must-not-live">Where the key must not live</h3>
<p>The common mistake is keeping the keystore in the project folder and committing it to Git, or keeping the only copy on one laptop. Store the <code>.jks</code> file outside the repository, back it up in at least two places (for example an encrypted cloud drive and a USB stick), and keep the passwords in a password manager. Google can reset a lost upload key, but a reset takes a support request and waiting, and a leaked key lets someone else upload as you until it is reset.</p>
<h3 id="wire-signing-into-gradle-without-leaking-passwords">Wire signing into Gradle without leaking passwords</h3>
<p>Never write passwords into <code>build.gradle.kts</code>. Put them in a <code>keystore.properties</code> file in the project root:</p>
<pre><code># keystore.properties (add this file to .gitignore!)
storeFile=/home/you/keys/upload-keystore.jks
storePassword=your-keystore-password
keyAlias=upload
keyPassword=your-key-password</code></pre>
<p>And add these lines to <code>.gitignore</code> right away:</p>
<pre><code>keystore.properties
*.jks
*.keystore</code></pre>
<p>Then load it in <code>app/build.gradle.kts</code>. This version also reads environment variables, so the same file works on a build server later, and someone who clones the project without the keys still gets a working unsigned build instead of an error:</p>
<pre><code>import java.io.FileInputStream
import java.util.Properties

val keystoreProps = Properties().apply {
    val f = rootProject.file("keystore.properties")
    if (f.exists()) FileInputStream(f).use { load(it) }
}
fun signingValue(key: String, env: String): String? =
    System.getenv(env) ?: keystoreProps.getProperty(key)

android {
    signingConfigs {
        create("release") {
            val path = signingValue("storeFile", "RELEASE_STORE_FILE")
            if (path != null) {
                storeFile = file(path)
                storePassword =
                    signingValue("storePassword", "RELEASE_STORE_PASSWORD")
                keyAlias = signingValue("keyAlias", "RELEASE_KEY_ALIAS")
                keyPassword =
                    signingValue("keyPassword", "RELEASE_KEY_PASSWORD")
            }
        }
    }
    buildTypes {
        release {
            optimization { enable = true }
            // Sign only when keys are available; otherwise build unsigned.
            if (signingValue("storeFile", "RELEASE_STORE_FILE") != null) {
                signingConfig = signingConfigs.getByName("release")
            }
        }
    }
}</code></pre>
<p>The book's example comes from a real repository: the author's Flashlight app (chartmann1590/Flashlight) ships a <code>keystore.properties.example</code> with empty values, so anyone who clones the project knows exactly what to provide without anything being exposed.</p>
<h2 id="a-bonus-rule-versioncode-only-goes-up">A bonus rule: versionCode only goes up</h2>
<p>Every upload needs a <code>versionCode</code> greater than the one before, and a value can never be reused. That includes builds uploaded to internal tests and builds you later abandon. The author's Dosely app is the book's example: version 1.1.1 had to go out as versionCode 4 because versionCode 3 had already been used by an earlier upload.</p>
<p>The greatest value Google Play allows is 2100000000. Don't jump to a huge number "to be safe" or use a full date-and-time stamp; it can get close to the ceiling quickly. Small, steady increments last forever:</p>
<pre><code>defaultConfig {
    versionCode = 1        // 1, 2, 3 ... one more for every upload
    versionName = "1.0.0"  // major.minor.patch, for humans
}</code></pre>
<h2 id="checklist-before-the-first-upload">Checklist before the first upload</h2>
<ul><li><code>applicationId</code> is yours, not <code>com.example</code>, and follows the naming rules</li><li>Upload key created: RSA 2048 or more, valid 25 years or more</li><li>Keystore stored outside the repo and backed up in two places</li><li>Passwords in a password manager; <code>keystore.properties</code> and <code>*.jks</code> in <code>.gitignore</code></li><li><code>./gradlew bundleRelease</code> produces <code>app-release.aab</code></li><li>The release variant installed and tested on a real phone (<code>./gradlew installRelease</code>)</li><li><code>versionCode</code> increased since the last upload</li></ul><p><a href="https://payhip.com/b/Qdxgz">Go deeper: Ship Your First Android App</a></p>]]></content:encoded></item><item><title>Shape &amp; Shade: find your face shape on your phone, without uploading your selfie</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/shape-and-shade-face-shape-app/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/shape-and-shade-face-shape-app/</guid>
<pubDate>Wed, 14 Oct 2026 12:30:00 -0400</pubDate><description>Shape &amp; Shade estimates your face shape from your own measurements, on your phone, and gives you hairstyle, glasses, brow, beard and contour ideas for it. No upload, no account, no beauty scores. Coming soon to Google Play.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.shapeshade.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>face-shape</category><category>on-device-ai</category><category>privacy</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.shapeshade.jpg" alt=""></p><p>Most face shape tools work the same way: you upload a selfie to someone's server and get a verdict back. Some want an account first, a few put the answer behind a paywall, and almost none tell you <em>why</em> they picked "oval". Shape &amp; Shade is a new Android app from Hartmann Studios that does the whole thing on your phone and shows its work.</p>
<h2 id="how-a-scan-works">How a scan works</h2>
<p>Tap <strong>Find my face shape</strong>, fit your face in the oval and take a photo, or pick one you already have (the system Photo Picker, so the app needs no storage permission). On-device AI, Google's MediaPipe Face Landmarker, finds 478 points on your face. That model ships inside the app, so the scan doesn't need a server.</p>
<p>From those points the app measures four things: forehead width, cheekbone width, jaw width and face length. The face shape <em>decision</em> is plain math on those ratios, not a black box: the app compares your proportions with six reference shapes (oval, round, square, heart, diamond, oblong) and picks the closest one. If you're close to two, it says so, for example "between heart and oval".</p>
<h2 id="what-you-get">What you get</h2>
<ul><li><strong>Your shape, with the reason.</strong> The measurement lines are drawn on your photo, with a "Why this shape" note in plain language, such as "Your forehead is wider than your jaw" and "Your chin is narrow".</li><li><strong>Guides for your shape.</strong> Hairstyles, glasses frames, brows, beard and contour ideas. The top three in each category are free.</li><li><strong>History.</strong> Your scans stay on your phone, and one tap in Settings deletes all of them.</li></ul>
<h2 id="pro">Pro</h2>
<p>Pro adds a <strong>Color Season</strong> scan (hold a white sheet of paper for white balance, in daylight; you get one of 12 seasons, a palette, and colors to skip), <strong>live drape</strong> (colors tinted under your chin on camera, two side by side), <strong>glasses try-on</strong>, the full guides, unlimited history and no ads. Color season is for fun and style ideas, not a rule.</p>
<h2 id="what-it-never-does">What it never does</h2>
<p>It never rates how you look. There are no beauty scores, no attractiveness numbers, and no guessing at age, ethnicity or gender. Face shape is an estimate from your measurements.</p>
<h2 id="privacy">Privacy</h2>
<p>Your selfie stays on your phone. There's no account and no upload. The free version shows ads (Google's consent form is in Settings, under Privacy choices).</p>
<p>Shape &amp; Shade is coming soon to Google Play, free with ads, in 22 languages. Until the listing is live, the screenshots and the promo video are on the app's site: <a href="https://chartmann1590.github.io/shapeshade-site/">chartmann1590.github.io/shapeshade-site</a>.</p><p><a href="https://chartmann1590.github.io/shapeshade-site/">Shape &amp; Shade: Face Shape: coming soon to Google Play</a></p>]]></content:encoded></item><item><title>A backup job that never runs never fails</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/backup-job-that-never-runs-never-fails/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/backup-job-that-never-runs-never-fails/</guid>
<pubDate>Wed, 14 Oct 2026 12:30:00 -0400</pubDate><description>Failure alerts can&#x27;t warn you about a backup job that stopped running. A vzdump hook that pings a dead-man&#x27;s switch, plus a weekly backup-age report, turns that silence into a phone notification.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-20.jpg" type="image/jpeg" length="0"/>
<category>proxmox</category><category>backups</category><category>vzdump</category><category>monitoring</category><category>alerts</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-20.jpg" alt=""></p><p>Backups fail in two ways. Loudly, when a job runs and reports an error. And quietly, when a job stops running at all: the timer was disabled during an upgrade, the node was off, the cron line was lost in a rebuild, the API key expired.</p>
<p>Loud failures are easy, because every backup tool can email you. Quiet failures need a different shape of alert, and they are the ones that catch people out.</p>
<h2 id="the-trap-alerting-only-on-failure">The trap: alerting only on failure</h2>
<p><strong>A job that never runs never fails.</strong> An off-site rclone sync that stopped in February will not send a single error between then and the day you need it. Your inbox stays clean the whole time, and a clean inbox looks exactly like a healthy backup.</p>
<p>So the alert you need is not "something failed". It is "nothing succeeded recently". That takes three pieces: notifications for the loud failures, a dead-man's switch for the quiet ones, and a report that checks every guest is actually covered.</p>
<h2 id="step-1-route-the-loud-failures-to-your-phone">Step 1: route the loud failures to your phone</h2>
<p>Since Proxmox VE 8.1 there is a proper notification system under <strong>Datacenter → Notifications</strong>. It has two parts.</p>
<ul><li><strong>Targets</strong> are where notifications go: SMTP (your own mail account), Gotify, a generic webhook, or the local sendmail. A webhook to a self-hosted ntfy server pushes straight to your phone, which matters because email from a homelab tends to land in spam exactly when it counts.</li><li><strong>Matchers</strong> decide which events go to which target. The default matcher sends everything to the mail-to-root target. Add one for backups: match field <code>type</code> equals <code>vzdump</code>, severity error and warning, target: your phone. A second matcher can send info events to email if you like a morning receipt.</li></ul>
<p>One setting is easy to miss: every backup job's <strong>Notification mode</strong> has to be set to the notification system, or the matchers never see it. On the command line that is:</p>
<pre><code>vzdump --all 1 --exclude 900,901 --mode snapshot --compress zstd \
  --notification-mode notification-system</code></pre>
<p>The older <code>--mailto</code> option still works, but it is deprecated; the notification system replaces it.</p>
<p>Proxmox Backup Server has the same system under <strong>Configuration → Notifications</strong> for its own verify, prune, GC and sync jobs. Wire the sync job's failures to the same phone target, because the sync job is your off-site copy.</p>
<h2 id="step-2-a-dead-man-s-switch-for-the-quiet-failures">Step 2: a dead-man's switch for the quiet failures</h2>
<p>For "nothing succeeded recently", use a check that <em>expects</em> a ping. A dead-man's switch service such as healthchecks.io, or a self-hosted Uptime Kuma push monitor, gives you a URL. Your job pings it on success. If the pings stop, you get a message.</p>
<p>vzdump has hook scripts for exactly this. Add one line to <code>/etc/vzdump.conf</code>:</p>
<pre><code>script: /usr/local/bin/vzdump-hook.sh</code></pre>
<p>Then the hook itself:</p>
<pre><code>#!/bin/bash
# vzdump calls this with the phase as $1
URL=https://hc-ping.com/your-uuid-here
case "$1" in
  job-end)   curl -fsS -m 10 --retry 3 "$URL" &gt;/dev/null ;;
  job-abort) curl -fsS -m 10 --retry 3 "$URL/fail" &gt;/dev/null ;;
esac
exit 0</code></pre>
<p>A clean finish pings the check; an aborted job pings its <code>/fail</code> URL so you hear about it straight away.</p>
<p>Set the check's period to a day and its grace to a few hours. A night the job does not run, for any reason, becomes a phone notification by breakfast.</p>
<p>Do the same for everything else on a timer: the rclone sync (a <code>&amp;&amp; curl</code> at the end of the cron line) and the database dump timer.</p>
<h2 id="step-3-a-weekly-report-of-each-guest-s-newest-backup">Step 3: a weekly report of each guest's newest backup</h2>
<p>Hooks tell you a job ran. They do not tell you it covered every guest. A job built for a hand-picked list of guests keeps pinging happily while every VM you created after it goes unprotected.</p>
<p>So once a week, a script on the node lists the newest backup of every guest and complains about any older than two days:</p>
<pre><code>#!/bin/bash
# newest backup per guest on a storage; flag any older than MAXAGE days
STORE=pbs01; MAXAGE=2; now=$(date +%s)
API=/nodes/$(hostname)/storage/$STORE/content
pvesh get $API --content backup --output-format json \
 | jq -r '.[] | "\(.vmid) \(.ctime)"' | sort -k1,1n -k2,2nr | sort -u -k1,1n \
 | while read -r vmid ctime; do
     age=$(( (now - ctime) / 86400 ))
     [ "$age" -gt "$MAXAGE" ] &amp;&amp; echo "STALE: guest $vmid, last backup ${age}d"
   done
ids="$(qm list | awk 'NR&gt;1{print $1}') $(pct list | awk 'NR&gt;1{print $1}')"
for id in $ids; do
  pvesh get $API --content backup --vmid "$id" --output-format json \
    | jq -e 'length &gt; 0' &gt;/dev/null || echo "NONE: guest $id has no backup"
done</code></pre>
<p>Change <code>STORE</code> to your backup storage's name. Send the output to ntfy or email only when it is not empty, so a quiet week stays quiet.</p>
<p>The second loop is the important one. It catches a guest that has never been backed up at all, which is exactly the gap a hand-picked job list leaves.</p>
<h2 id="step-4-let-home-assistant-watch-its-own-backups">Step 4: let Home Assistant watch its own backups</h2>
<p>Home Assistant's Backup integration exposes sensors for the backup manager's state and the last successful automatic backup. An automation that fires when the last success is more than two days old closes the loop for the one system in the house that already knows how to notify your phone:</p>
<pre><code>alias: Backup is stale
triggers:
  - trigger: time
    at: "09:00:00"
conditions:
  - condition: template
    value_template: &gt;
      {% set last = states('sensor.backup_last_successful_automatic_backup') %}
      {{ (now() - last | as_datetime) &gt; timedelta(days=2) }}
actions:
  - action: notify.mobile_app_your_phone
    data:
      message: "Home Assistant has not completed a backup in over two days."</code></pre>
<p>Check the sensor's exact entity ID in <strong>Developer Tools → States</strong>. The Backup integration creates it once automatic backups are configured.</p>
<h2 id="step-5-watch-the-disk-before-it-fails-the-job">Step 5: watch the disk before it fails the job</h2>
<p>Backup storage filling up is a backup failure in waiting, and a common cause of failed jobs. Proxmox shows storage usage on the Datacenter summary; PBS shows datastore usage and has its own notification for it.</p>
<p>A simple rule: when the backup target passes <strong>80 percent</strong>, either prune harder or add a disk, and do it that week.</p>
<h2 id="step-6-break-something-on-purpose">Step 6: break something on purpose</h2>
<p>An alert you have never seen fire is in the same category as a backup you have never restored. Test every path once:</p>
<ul><li>Disable the backup job for a day and confirm the dead-man's switch fires.</li><li>Point rclone at a bucket name that does not exist and confirm you hear about it.</li></ul>
<p>If nothing arrives, fix the alert before you trust anything else in this post.</p>
<h2 id="checklist-monitoring">Checklist: monitoring</h2>
<ul><li>Notification targets set up (phone push plus email), with a matcher for vzdump warnings and errors</li><li>Every backup job on the notification system, on Proxmox and on PBS</li><li>A dead-man's switch pinged by the vzdump hook, the rclone sync and the database dump timer</li><li>A weekly backup-age report that also lists guests with no backup at all</li><li>A Home Assistant automation that complains when its own backup is stale</li><li>Backup storage usage watched, with 80 percent as the act-now line</li><li>Every alert path deliberately triggered once</li></ul><p><a href="https://payhip.com/b/1iHXQ">Go deeper: Never Lose Your Data</a></p>]]></content:encoded></item><item><title>Aria: a private voice assistant that runs on your Android phone</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/aria-on-device-ai-assistant/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/aria-on-device-ai-assistant/</guid>
<pubDate>Wed, 14 Oct 2026 12:30:00 -0400</pubDate><description>Aria handles speech recognition, the language model and voice output on your phone. No cloud LLM, no voice uploads, no account.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.aria.assistant.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>voice-assistant</category><category>on-device-ai</category><category>privacy</category><category>offline</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.aria.assistant.jpg" alt=""></p><p>Using a typical voice assistant means accepting a trade. To get answers, your voice is recorded and sent to a server, processed there, and often kept. If you run a homelab, that kind of trade is probably what pushed you toward self-hosting in the first place. Aria is a voice assistant built the other way around: the listening, the thinking and the talking all happen on your Android device.</p>
<h2 id="what-runs-on-the-phone">What runs on the phone</h2>
<p>According to the listing, Aria does all of this locally:</p>
<ul><li><strong>Speech recognition</strong>, turning what you say into text.</li><li><strong>Wake-word detection</strong>, so it can listen for its trigger without streaming audio anywhere.</li><li><strong>Language model inference</strong>, the part that actually understands and answers your request.</li><li><strong>Voice synthesis</strong>, for spoken replies.</li><li><strong>Translation</strong>, also handled on the device.</li><li><strong>Text-to-speech in multiple languages.</strong></li></ul>
<p>The key line is that there is no cloud LLM. Many assistants that call themselves private still send the actual request to a hosted model. Aria's listing says it does not, and that your voice is not uploaded. Conversation history stays local, and no account is required.</p>
<h2 id="optional-features-and-what-they-need">Optional features, and what they need</h2>
<p>Some useful things an assistant can do require more than a microphone. Aria treats those as opt-in. The listing names:</p>
<ul><li><strong>Web search</strong>, with visible source cards so you can see where an answer came from.</li><li><strong>Notification access</strong>, for smart replies.</li><li><strong>Phone, contacts, calendar, camera and location</strong> access.</li></ul>
<p>Permissions are requested only when you use the feature that needs them. If you never turn on web search, Aria never needs to reach the web for it. If you never ask it to deal with your calendar, it never asks for calendar access.</p>
<p>That approach is worth understanding before you start. The core assistant works offline and on the device. The extras are there when you want them, and each one comes with its own permission prompt at the moment you first use it, rather than a long list of grants on install.</p>
<h3 id="web-search-with-sources">Web search with sources</h3>
<p>The web search option is the one feature that, by nature, has to touch the internet. The listing's detail here is the source cards: when Aria uses search, the sources are shown, so you are not left guessing whether an answer came from the model or from a page.</p>
<h2 id="why-on-device-matters">Why on-device matters</h2>
<p>The listing puts the case simply: most assistants send your voice and data to the cloud, and Aria keeps everything on your device. In practice that means:</p>
<ul><li>Nothing you say has to leave the phone for the assistant to work.</li><li>It keeps working in places with no connection.</li><li>There is no account tying your history to an identity on a server.</li></ul>
<h2 id="good-to-know">Good to know</h2>
<ul><li><strong>Price:</strong> free to download. The app contains ads, and the Play listing notes in-app purchases.</li><li><strong>Category:</strong> Tools, rated Everyone.</li><li><strong>Offline by default:</strong> the core features run without a network; web search is the opt-in exception.</li><li><strong>Permissions on demand:</strong> phone, contacts, calendar, camera, location and notification access are only requested when you use a feature that needs them.</li></ul>
<h2 id="is-it-for-you">Is it for you?</h2>
<ul><li>You want a voice assistant but do not want your voice uploaded.</li><li>You spend time somewhere offline, or you travel often.</li><li>You want to decide feature by feature what an assistant can access.</li><li>You want search answers to show their sources.</li></ul><p><a href="https://play.google.com/store/apps/details?id=com.aria.assistant">Get Aria: On-Device Assistant on Google Play</a></p>]]></content:encoded></item><item><title>The Homelab the Right Way merch store is open: every item, reviewed</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/merch-store-open-every-item-reviewed/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/merch-store-open-every-item-reviewed/</guid>
<pubDate>Tue, 13 Oct 2026 12:30:00 -0400</pubDate><description>The merch store is open. Here is every item in it, the design behind each one, and a one-minute video review that shows the real products.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/merch/showcase-hero.jpg" type="image/jpeg" length="0"/>
<category>merch</category><category>homelab</category><category>selfhosted</category><category>store</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/merch/showcase-hero.jpg" alt=""></p><p>The <a href="https://homelab-the-right-way.printful.me/">Homelab the Right Way merch store</a> is open. Thirteen items, all printed or embroidered to order, each one built around a running joke or a rule from the books. The video above is a one-minute review of the real products (the host is an AI-generated character; the product shots are the store's own).</p>
<h2 id="tees">Tees</h2>
<ul><li><a href="https://homelab-the-right-way.printful.me/product/theres-no-place-like-127001-tee">There's No Place Like 127.0.0.1</a>: the house, the loopback address and a "Homelab · the right way" underline. If you know, you know.</li><li><a href="https://homelab-the-right-way.printful.me/product/3-2-1-or-it-didnt-happen-tee">3-2-1 or It Didn't Happen</a>: three copies, two kinds of media, one off-site. The backup rule from <em>Never Lose Your Data</em>, on a shirt.</li><li><a href="https://homelab-the-right-way.printful.me/product/homelab-the-right-way-crest-tee">Homelab the Right Way Crest</a>: the channel crest.</li><li><a href="https://homelab-the-right-way.printful.me/product/turtle-ops-tee-smart-turtle-tank">Turtle Ops</a>: for the smart turtle tank build.</li></ul>
<h2 id="hoodies">Hoodies</h2>
<ul><li><a href="https://homelab-the-right-way.printful.me/product/sudo-fix-everything-hoodie">sudo fix-everything</a>: a terminal line for the command everyone wishes existed.</li><li><a href="https://homelab-the-right-way.printful.me/product/rack-stack-hoodie">Rack Stack</a>: a rack diagram across the chest.</li></ul>
<h2 id="hats">Hats</h2>
<p>Three hats, all embroidered rather than printed: the <a href="https://homelab-the-right-way.printful.me/product/homelab-dad-hat">&gt;homelab. dad hat</a>, the <a href="https://homelab-the-right-way.printful.me/product/127001-snapback">127.0.0.1 snapback</a> and the <a href="https://homelab-the-right-way.printful.me/product/right-way-turtle-beanie">Right Way turtle beanie</a>.</p>
<h2 id="desk-and-wall">Desk and wall</h2>
<ul><li><a href="https://homelab-the-right-way.printful.me/product/its-always-dns-mug">It's Always DNS mug</a>, 11 oz. Because it is.</li><li><a href="https://homelab-the-right-way.printful.me/product/homelab-network-map-desk-mat">Network map desk mat</a>, 12 x 22 inches, a full-bleed navy network map with the title along the edge.</li><li><a href="https://homelab-the-right-way.printful.me/product/the-3-2-1-backup-rule-poster">The 3-2-1 Backup Rule poster</a>, 18 x 24 inches.</li><li><a href="https://homelab-the-right-way.printful.me/product/turtle-ops-sticker-kiss-cut">Turtle Ops sticker</a>, 4 x 4 inches, for a laptop lid or a rack door.</li></ul>
<h2 id="how-ordering-works">How ordering works</h2>
<p>Everything is made on demand by Printful and shipped from their facilities, so there is no stock to run out of and each item is printed when you order it. Sizes, colors and current prices are on each product page. The full list with photos is on the <a href="https://chartmann1590.github.io/homelab-the-right-way/merch/">merch page</a>, and the store link is in the link tree.</p><p><a href="https://payhip.com/b/mJKnI">Go deeper: Homelab the Right Way</a></p>]]></content:encoded></item><item><title>DS18B20 reads 85 °C or -127 °C? Here&#x27;s why</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/ds18b20-reads-85c-or-minus-127c/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/ds18b20-reads-85c-or-minus-127c/</guid>
<pubDate>Tue, 13 Oct 2026 12:30:00 -0400</pubDate><description>A DS18B20 that reports 85 °C hasn&#x27;t measured anything; that is its power-on value, and -127 °C means no probe is connected. How the tank monitor&#x27;s firmware and ESPHome drop both, and the wire to check when the probe is stuck at 85.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-19.jpg" type="image/jpeg" length="0"/>
<category>esp32</category><category>ds18b20</category><category>esphome</category><category>turtle tank</category><category>diy</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-19.jpg" alt=""></p><p>The DS18B20 is the easy sensor in a water-quality monitor. It is a waterproof stainless probe, the probe and its cable can go fully underwater, and it is accurate to ±0.5 °C out of the box with no calibration maths. So when the tank card suddenly says the water is 85 °C, or -127 °C, it is tempting to blame the probe, the board or the tank heater.</p>
<p>None of those is the cause. Both numbers are the sensor telling you it has no reading. This post covers what each one means, how the Smart Turtle Tank firmware keeps them off your graphs, and where to look when the probe will not stop saying 85.</p>
<h2 id="the-trap-two-numbers-that-look-like-readings">The trap: two numbers that look like readings</h2>
<p>The DS18B20 is a digital sensor. It talks over a single wire using a protocol called 1-Wire and sends the temperature as a number rather than as a voltage. That is why it is the accurate one, and also why it can hand you a perfectly formatted number that has nothing to do with the water.</p>
<p>There are two of these:</p>
<div class="table"><table><thead><tr><th>Value</th><th>What it actually means</th></tr></thead><tbody><tr><td>85 °C</td><td>The power-on value, before the first conversion finishes. The sensor never got a successful read.</td></tr><tr><td>-127 °C</td><td>What the Dallas library returns when the probe is not connected.</td></tr></tbody></table></div>
<p>Neither is a temperature. If your code passes them through, the server stores them like any other reading, and your graph and alerts treat them as real. The book's alert rule fires when the water goes under 73 °F or over 85 °F, so a fake 85 °C sails straight past the upper limit and sets off an alert for water that is fine.</p>
<h2 id="the-fix-in-the-firmware">The fix in the firmware</h2>
<p>The Smart Turtle Tank firmware reads the probe once per sample with the OneWire and DallasTemperature libraries (in <code>platformio.ini</code> they are <code>paulstoffregen/OneWire</code> and <code>milesburton/DallasTemperature</code>). The probe sits on D4:</p>
<pre><code>#define TEMP_PIN D4        // GPIO6, DS18B20 DAT (adapter has the pull-up)</code></pre>
<p>And this is the part of the sample loop that does the rejecting:</p>
<pre><code>temp.requestTemperatures();        // ~750 ms at 12-bit
float t = temp.getTempCByIndex(0);
if (t &gt; -100 &amp;&amp; t != 85.0f) { accT += t; nT++; } // drop -127 and 85</code></pre>
<p>Line by line:</p>
<ul><li><code>temp.requestTemperatures()</code> tells the DS18B20 to measure. At 12-bit resolution that takes about 750 ms, and the call waits for it.</li><li><code>getTempCByIndex(0)</code> reads the first (and only) probe on the wire.</li><li>The <code>if</code> throws away -127 (no probe) and exactly 85.0 (the power-on value), and counts only good reads in <code>nT</code>.</li></ul>
<p>Every five minutes (ten samples) the firmware averages the good samples into one reading. When there were none, the temperature field is sent as <code>null</code>. A null on the graph is honest; a fake 85 °C would page you.</p>
<p>The server backs this up. Its validation accepts <code>t</code> only as null or between -5 and 50 °C, so anything else is rejected with a 400 and never reaches history.</p>
<h2 id="the-fix-in-esphome">The fix in ESPHome</h2>
<p>If you would rather run the probe through ESPHome and Home Assistant, the same rejection is two filter lines on the <code>dallas_temp</code> sensor:</p>
<pre><code>one_wire:
  - platform: gpio
    pin: GPIO6            # XIAO D4

sensor:
  - platform: dallas_temp
    name: "Tank water temperature"
    update_interval: 30s
    filters:
      - filter_out: 85.0
      - filter_out: -127.0</code></pre>
<p><code>one_wire:</code> sets up the 1-Wire bus on GPIO6, which is the XIAO's D4, and the <code>dallas_temp</code> sensor finds the probe on it. The two <code>filter_out</code> lines drop the 85 °C power-on value and the -127 °C unplugged value, the same job the PlatformIO firmware does.</p>
<h2 id="stuck-at-85-check-the-wiring-not-the-code">Stuck at 85: check the wiring, not the code</h2>
<p>Filtering keeps the bad values off the graph, but it does not fix them. If the temperature field stays null, or the raw value keeps coming back as 85 or -127, the sensor is not getting a clean read. The book's troubleshooting puts it plainly: an 85 °C reading usually means a loose DAT wire.</p>
<p>The probe has three wires that screw into a small adapter board, matched by the printed letters:</p>
<ul><li><strong>Red</strong> to <strong>VCC</strong></li><li><strong>Yellow</strong> to <strong>DAT</strong></li><li><strong>Black</strong> to <strong>GND</strong></li></ul>
<p>Then three jumpers connect the adapter to the XIAO:</p>
<ul><li>The + rail (3V3) to the adapter's <strong>VCC</strong> pin.</li><li>The - rail (GND) to the adapter's <strong>GND</strong> pin.</li><li>XIAO <strong>D4</strong> to the adapter's <strong>DAT</strong> pin.</li></ul>
<p>Two details catch people here. First, the pins on your adapter may be in a different order than any picture you are following, so go by the printed letters, not by position. Second, the adapter belongs on 3V3, not 5V. Its VCC comes from the + rail, which is fed by the XIAO's 3V3 pin, so the signal on DAT stays under the 3.3 V the XIAO's pins can take.</p>
<p>You also do not need to add a resistor. The DS18B20 needs a 4.7 kΩ pull-up on its data line, and the kit's adapter board already has it built in.</p>
<p>Once the probe reads, the only adjustment left is a one-time offset. Put the DS18B20 and a reference aquarium thermometer in the same cup of water for five minutes; the difference is <code>temp_offset_c</code>, a setting on the server. Expect a few tenths of a degree, since the probe is rated ±0.5 °C.</p>
<h2 id="checklist">Checklist</h2>
<ul><li>Probe wires screwed into the adapter by letter: red to VCC, yellow to DAT, black to GND, each one tugged gently.</li><li>Adapter VCC from the 3V3 (+) rail, GND from the - rail, DAT to XIAO D4.</li><li>No extra pull-up resistor; the adapter has the 4.7 kΩ one.</li><li>Only the metal probe and its cable go in the water. The adapter board stays dry, with the cable hanging in a U-shaped drip loop below the electronics.</li><li>Firmware drops <code>-127</code> and exactly <code>85.0</code> and sends <code>null</code> when there was no good read.</li><li>In ESPHome, <code>filter_out: 85.0</code> and <code>filter_out: -127.0</code> are on the <code>dallas_temp</code> sensor.</li><li>Still null or stuck at 85? Re-seat the DAT, VCC and GND wires and confirm the adapter is on 3V3.</li><li>Probe reading for real? Set <code>temp_offset_c</code> once against a reference thermometer.</li></ul><p><a href="https://payhip.com/b/k0nzS">Go deeper: Smart Turtle Tank</a></p>]]></content:encoded></item><item><title>Android the Right Way: 5 new ebooks on making Android apps</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/android-the-right-way-5-new-ebooks/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/android-the-right-way-5-new-ebooks/</guid>
<pubDate>Tue, 13 Oct 2026 12:30:00 -0400</pubDate><description>A new five-book series on building and shipping Android apps, from an empty Android Studio project to Play review, monetization and on-device AI. Every book has a free sample chapter.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>kotlin</category><category>jetpack compose</category><category>google play</category><category>ebook</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og.jpg" alt=""></p><p>The store has a second series today: <strong>Android the Right Way</strong>, five practical ebooks on building Android apps and getting them onto Google Play. They sit alongside the six Homelab the Right Way books, and they are written the same way: short chapters, real code, diagrams instead of walls of text, and a checklist at the end.</p>
<p>The author ships Android apps on Google Play as Hartmann Studios, and the books use real, public examples from those apps where they help (the <a href="https://chartmann1590.github.io/homelab-the-right-way/apps/">apps page</a> lists them). Every version number, limit and deadline in the series was checked against Google's official pages in October 2026, and the books name the page.</p>
<p>Each book is a PDF and EPUB, $19, and each one has a free sample: the cover, the full table of contents and one complete chapter.</p>
<h2 id="which-one-to-start-with">Which one to start with</h2>
<ul><li>New to Android, or never published an app: start with <strong>Book 1</strong>.</li><li>You can build screens but Compose keeps surprising you: <strong>Book 2</strong>.</li><li>Your app exists and Play Console keeps saying no: <strong>Book 3</strong>.</li><li>It's live and you want it to pay for itself: <strong>Book 4</strong>.</li><li>You want AI features without a cloud bill: <strong>Book 5</strong>, which ends with an Android client for your own Ollama server at home.</li></ul>
<h2 id="book-1-ship-your-first-android-app">Book 1: Ship Your First Android App</h2>
<p><em>From Empty Project to Google Play: Android Studio, Kotlin, Signing, Testing Tracks and Your First Release</em></p>
<p>From an empty Android Studio project to a real app live on Google Play: setup, a first Kotlin + Compose app, the decisions you can't undo, a signed release build, Play Console and the 12-tester closed test.</p>
<p><strong>Who it's for:</strong> You have an app idea, or a project that runs on your phone, and you want it live on Google Play.</p>
<p>62 pages, 23 diagrams. The free sample is all of Chapter 7, "Testing Tracks and the 12-Tester Rule".</p>
<p><a href="https://payhip.com/b/Qdxgz">Get the book</a> · <a href="https://payhip.com/b/mIYh9">Read the free sample</a></p>
<h2 id="book-2-compose-without-the-confusion">Book 2: Compose Without the Confusion</h2>
<p><em>Kotlin and Jetpack Compose Fundamentals: State, Layouts, Navigation, ViewModels and Room</em></p>
<p>Kotlin and Jetpack Compose that make sense: state and recomposition, layouts that behave, Material 3, Navigation 3, ViewModels with StateFlow, and Room.</p>
<p><strong>Who it's for:</strong> You write Compose screens but state, recomposition and ViewModels still feel like guesswork.</p>
<p>67 pages, 25 diagrams. The free sample is all of Chapter 4, "State Without the Confusion".</p>
<p><a href="https://payhip.com/b/fNO53">Get the book</a> · <a href="https://payhip.com/b/LsD1t">Read the free sample</a></p>
<h2 id="book-3-pass-google-play-review">Book 3: Pass Google Play Review</h2>
<p><em>Target SDK, 16 KB Pages, Data Safety, Permissions and Automated Releases Without Rejections</em></p>
<p>Get an Android app through Google Play review and keep it there: target API 36, 16 KB page size, sensitive permissions, Data safety and account deletion, honest listings and vitals.</p>
<p><strong>Who it's for:</strong> You publish on Google Play and want releases to stop getting rejected or blocked.</p>
<p>59 pages, 25 diagrams. The free sample is all of Chapter 3, "16 KB Page Size".</p>
<p><a href="https://payhip.com/b/YcoD4">Get the book</a> · <a href="https://payhip.com/b/VcvYH">Read the free sample</a></p>
<h2 id="book-4-get-paid-for-your-android-app">Book 4: Get Paid for Your Android App</h2>
<p><em>AdMob, Consent, Play Billing Subscriptions and Store Listings That Convert, Done Honestly</em></p>
<p>The money side of an Android app done honestly: the right business model, AdMob with consent first, Play Billing from connection to acknowledgement, subscriptions, and listings that convert without overpromising.</p>
<p><strong>Who it's for:</strong> Your app is live and you want it to earn something without tricking users or breaking Google's rules.</p>
<p>62 pages, 24 diagrams. The free sample is all of Chapter 5, "Play Billing Fundamentals".</p>
<p><a href="https://payhip.com/b/zRXe3">Get the book</a> · <a href="https://payhip.com/b/8lBGE">Read the free sample</a></p>
<h2 id="book-5-on-device-ai-for-android">Book 5: On-Device AI for Android</h2>
<p><em>ML Kit, Local LLMs and Your Own Homelab AI Server in Kotlin Apps</em></p>
<p>Real AI features in a Kotlin app without sending everything to someone else's cloud: ML Kit, Gemini Nano, LiteRT, a local LLM on the phone, and a streaming chat with your own Ollama server.</p>
<p><strong>Who it's for:</strong> You want AI features in a Kotlin app that run on the phone or on your own server, not someone else's cloud.</p>
<p>60 pages, 23 diagrams. The free sample is all of Chapter 7, "Your Homelab AI Server".</p>
<p><a href="https://payhip.com/b/J9jlp">Get the book</a> · <a href="https://payhip.com/b/Hz16R">Read the free sample</a></p>
<h2 id="what-every-book-includes">What every book includes</h2>
<ul><li>Chapter-opener graphics and diagrams for the ideas that are easier to see than read</li><li>Code in current Kotlin and Jetpack Compose</li><li>A checklist and a cheat sheet</li><li>PDF and EPUB</li></ul>
<p>The free samples are the quickest way to see whether the style fits you. Over the next five days the blog has one post from each book, starting tomorrow with the two decisions you can't undo before your first Google Play upload.</p><p><a href="https://payhip.com/b/Qdxgz">Go deeper: Ship Your First Android App</a></p>]]></content:encoded></item><item><title>PixelDream: generate AI images offline, entirely on your phone</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/pixeldream-offline-ai-image-generator/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/pixeldream-offline-ai-image-generator/</guid>
<pubDate>Tue, 13 Oct 2026 12:30:00 -0400</pubDate><description>PixelDream turns a short description into an image using AI models that run on your phone. No account, no uploads, no internet after setup.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.hartmann.pixeldream.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>ai-images</category><category>offline</category><category>privacy</category><category>stable-diffusion</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.hartmann.pixeldream.jpg" alt=""></p><p>Most AI image generators work the same way: you type a prompt, it goes to a server, and a picture comes back. That means an account, a connection, and your prompts sitting on somebody else's machine. PixelDream takes the other route. The models run on your phone, so once setup is done you can create images with no Wi-Fi, no mobile data and no account.</p>
<h2 id="what-it-does">What it does</h2>
<ul><li><strong>Text to image, on the device.</strong> Describe what you are imagining, tap Create, and a local image model renders it using your phone's own hardware.</li><li><strong>Optional prompt enhancement.</strong> An on-device AI can expand a short idea into a richer prompt before rendering, so you do not need prompt-engineering experience. You can switch this off.</li><li><strong>Works anywhere.</strong> After the one-time setup download, generation needs no network, which the listing pitches for planes and off-grid use.</li><li><strong>A private local gallery.</strong> Every image is saved on the device, where you can revisit, export or share it when you choose.</li><li><strong>Quality and size controls.</strong> Pick generation quality and image size to trade detail against speed.</li><li><strong>Safety checks.</strong> On-device checks run before and after prompt enhancement to steer away from harmful content, and there is an in-app report option.</li></ul>
<h2 id="how-it-works">How it works</h2>
<p>The listing breaks it into three steps:</p>
<ol><li>Describe your idea in a sentence or two.</li><li>PixelDream optionally expands it into a fuller visual prompt, then a local diffusion model renders the image on your phone.</li><li>The finished image lands in your private gallery, ready to view, export or share.</li></ol>
<p>The prompt expansion is the part that makes it approachable. A one-line idea is often too thin for a diffusion model to do much with, and the on-device language model fills in the visual detail for you before the image model runs.</p>
<h2 id="what-your-phone-needs">What your phone needs</h2>
<p>Running image models locally is demanding, and the listing is specific about hardware. Check this before you install:</p>
<div class="table"><table><thead><tr><th>Requirement</th><th>Minimum</th><th>Recommended</th></tr></thead><tbody><tr><td>Android version</td><td>Android 10 (API 29)</td><td>Android 13 or newer</td></tr><tr><td>RAM</td><td>6 GB</td><td>8 GB or more</td></tr><tr><td>Free storage</td><td>5 GB</td><td>8 GB or more</td></tr><tr><td>Processor</td><td>64-bit ARM (arm64-v8a), octa-core</td><td></td></tr></tbody></table></div>
<p>The models total roughly 4.1 GB and download once over Wi-Fi during setup. The listing notes that older or lower-spec devices may run slowly or may not be supported.</p>
<h2 id="privacy">Privacy</h2>
<p>Image generation runs entirely on the device, and nothing about what you type or create is uploaded as part of making an image. Your prompts and pictures stay on the phone unless you choose to share them.</p>
<p>The listing is also clear about the exceptions: some diagnostics and the optional in-app feedback and bug-report tool use the network. Image generation itself does not.</p>
<h2 id="good-to-know">Good to know</h2>
<ul><li><strong>Price:</strong> free to download, with optional ads and an optional ad-free upgrade (an in-app purchase).</li><li><strong>Content filtering:</strong> the on-device checks try to steer prompts and results away from harmful content, but the listing states plainly that no filter is perfect. Use it responsibly and report anything that slips through.</li><li><strong>Problems:</strong> bugs can be reported from Settings, then Report a Problem.</li><li><strong>Setup download:</strong> plan for a 4.1 GB download on Wi-Fi before first use.</li></ul>
<h2 id="is-it-for-you">Is it for you?</h2>
<ul><li>You want to make AI images without an account or a subscription service.</li><li>You want your prompts and images to stay on your own phone.</li><li>You want something that works on a plane or with no signal.</li><li>Your phone meets the RAM, storage and processor requirements above.</li></ul><p><a href="https://play.google.com/store/apps/details?id=com.hartmann.pixeldream">Get PixelDream: Offline AI Images on Google Play</a></p>]]></content:encoded></item><item><title>Why your 2-node Proxmox cluster freezes</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/why-your-2-node-proxmox-cluster-freezes/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/why-your-2-node-proxmox-cluster-freezes/</guid>
<pubDate>Mon, 12 Oct 2026 12:30:00 -0400</pubDate><description>With two Proxmox nodes, losing either one leaves the survivor with one vote out of two, so it loses quorum and /etc/pve goes read-only. Here is how a QDevice adds a third vote without a third server, plus the Wi-Fi trap and the emergency lever.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-17.jpg" type="image/jpeg" length="0"/>
<category>proxmox</category><category>cluster</category><category>quorum</category><category>qdevice</category><category>corosync</category><category>homelab</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-17.jpg" alt=""></p><p>Two Proxmox nodes feel like redundancy. One box dies, the other carries on. That is the picture most people have when they join a second machine to the cluster.</p>
<p>Out of the box, it is the opposite: two machines that can each stop the other from working. This post covers why that happens, the small fix that solves it, and the two related traps that make a cluster look haunted.</p>
<h2 id="how-quorum-works-in-one-paragraph">How quorum works, in one paragraph</h2>
<p>A Proxmox cluster uses Corosync to agree on who is alive. Each node gets one vote, and the cluster is <strong>quorate</strong> when more than half the votes are present. Without quorum, the cluster filesystem at <code>/etc/pve</code> goes read-only. You can't start guests, change configs or create backups, and nothing in the UI saves.</p>
<h2 id="the-trap-1-vote-out-of-2-is-not-a-majority">The trap: 1 vote out of 2 is not a majority</h2>
<p>Now do the arithmetic for two nodes. Lose either one, and the survivor holds 1 vote out of 2. That is exactly half, and half is not <em>more</em> than half. So the survivor freezes.</p>
<p>That is the part that catches people. The node that is still up, still powered and still on the network is the one that stops working, because it has no way to know whether the other node is dead or just unreachable. Refusing to write is the safe choice for the cluster, and it is a very annoying choice for you.</p>
<p>You can see the vote count at any time:</p>
<pre><code>pvecm status</code></pre>
<h2 id="the-fix-a-third-vote-that-isn-t-a-third-server">The fix: a third vote that isn't a third server</h2>
<p>A <strong>QDevice</strong> is a tiny external voter. It can run on a Raspberry Pi, a NAS container or any always-on Linux box that isn't one of the cluster nodes. It gives the cluster a third vote without a third hypervisor.</p>
<p>With three votes, losing one node leaves 2 out of 3. That is more than half, so the survivor stays quorate and keeps working.</p>
<p>The setup is three installs and one command:</p>
<pre><code># On the external box (Debian/Ubuntu/Raspberry Pi OS):
apt install corosync-qnetd

# On EVERY cluster node:
apt install corosync-qdevice

# On ONE cluster node (root SSH to the QDevice box must work):
pvecm qdevice setup 192.168.10.5

# Check: you should see 3 total votes and "Qdevice" listed
pvecm status</code></pre>
<p>Replace <code>192.168.10.5</code> with the address of your own external box.</p>
<h3 id="where-the-qdevice-must-not-live">Where the QDevice must not live</h3>
<p>Don't run the QDevice as a VM or container on one of the cluster nodes. It is tempting, because the hardware is already there. But if that node goes down, it takes its own vote <strong>and</strong> the QDevice's vote with it, and you are back to 1 vote out of 3 on the survivor. Separate hardware is the whole point.</p>
<div class="table"><table><thead><tr><th>QDevice placement</th><th>One node dies</th></tr></thead><tbody><tr><td>VM or container on a cluster node</td><td>That node takes two votes with it; no quorum</td></tr><tr><td>Separate always-on box</td><td>The third vote survives; the other node keeps quorum</td></tr></tbody></table></div>
<h2 id="the-related-trap-a-node-on-wi-fi-that-keeps-flapping">The related trap: a node on Wi-Fi that keeps flapping</h2>
<p>Even with enough votes, a cluster can look broken if one member keeps dropping in and out. The usual cause is Wi-Fi.</p>
<p>Corosync is extremely sensitive to latency and jitter. It wants a stable, low-latency link: single-digit milliseconds, consistently. Wi-Fi delivers great average speeds and terrible consistency. A microwave, a neighbor's channel change or a roaming decision adds 50 to 300 ms of jitter for a few seconds. Corosync sees missed tokens, decides the node is gone, and the cluster reshuffles. A few seconds later it is back. Then it is gone again.</p>
<p>The symptoms look random:</p>
<ul><li>The node shows a red X, then turns green again</li><li><code>/etc/pve</code> is briefly read-only</li><li>Backup jobs fail with "cluster not ready"</li><li>The logs fill with lines like <code>[TOTEM] A processor failed, forming new configuration</code></li></ul>
<p>Check for it:</p>
<pre><code>journalctl -u corosync --since "24 hours ago" | \
  grep -Ei "failed|new membership|retransmit"
corosync-cfgtool -s        # link status per node
ping -c 100 -i 0.2 &lt;other-node&gt; | tail -2   # look at mdev/max, not avg</code></pre>
<p>Look at the <code>mdev</code> and <code>max</code> numbers from the ping, not the average. A good average with a big max is exactly the jitter Corosync hates.</p>
<p>The fix isn't a Corosync tuning trick. It's a cable. If the node physically can't be wired, use a powerline or MoCA adapter (still better than Wi-Fi), or take it out of the cluster and run it standalone. A standalone node you manage separately is far less trouble than a cluster member that drops out twice an hour.</p>
<p>If your nodes have two network ports, give Corosync a second link (<code>link1</code>) on a separate network or a direct cable between the nodes. Corosync fails over between links automatically.</p>
<h2 id="when-you-re-stuck-without-quorum-right-now">When you're stuck without quorum right now</h2>
<p>If one node of two is dead and you need the survivor working immediately, there is an emergency lever:</p>
<pre><code>pvecm expected 1</code></pre>
<p>This tells the survivor to accept being alone. It is an emergency lever, not a fix. Only use it when you are sure the other node is truly off, because two nodes that each think they are the whole cluster can write conflicting configs. Once the crisis is over, the real fix is still the QDevice.</p>
<h2 id="scripts-wait-for-quorum-instead-of-failing-halfway">Scripts: wait for quorum instead of failing halfway</h2>
<p>If you script changes on a cluster with a flaky member, check quorum before each write and retry, instead of letting the script die partway through a batch:</p>
<pre><code>until pvecm status | grep -q "Quorate:.*Yes"; do
  echo "waiting for quorum..."; sleep 10
done
pct destroy 123 --purge</code></pre>
<p>The loop waits until <code>pvecm status</code> reports the cluster as quorate, then runs the write. Wrap each destructive or config-changing step this way and a brief membership blip costs you a pause, not a half-finished job.</p>
<h2 id="checklist">Checklist</h2>
<ul><li>Every cluster node is wired, ideally on the same switch</li><li>Two-node clusters have a QDevice on separate hardware, never on a cluster node</li><li><code>pvecm status</code> shows the expected vote count (3 for two nodes plus a QDevice)</li><li>Corosync logs show no membership changes over the past day</li><li>Nodes with two ports give Corosync a second link (<code>link1</code>)</li><li>Scripts check for quorum before each write</li><li>You know the <code>pvecm expected 1</code> lever, and when not to pull it</li></ul><p><a href="https://payhip.com/b/mJKnI">Go deeper: Homelab the Right Way</a></p>]]></content:encoded></item><item><title>New book: Automations That Just Work (Home Assistant without the surprises)</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/automations-that-just-work-book-6/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/automations-that-just-work-book-6/</guid>
<pubDate>Mon, 12 Oct 2026 12:30:00 -0400</pubDate><description>Book 6 in the Homelab the Right Way series is out: reliable Home Assistant, from Zigbee coordinator placement to automations you can read a year later and a monthly update hour with a way back. Here is the motion-light automation from Chapter 4, done properly, and the one Zigbee change that fixes most dropped sensors.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og.jpg" type="image/jpeg" length="0"/>
<category>home assistant</category><category>zigbee</category><category>z-wave</category><category>automations</category><category>ebook</category><category>homelab</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og.jpg" alt=""></p><p>Book 6 in the series is out today: <strong>Automations That Just Work: Reliable Home Assistant with Zigbee, Z-Wave, Dashboards and Updates That Don't Break Things</strong>. It is 56 pages and 23 diagrams on the second six months of a Home Assistant install, the part where the magic has worn off and the family has stopped trusting the house.</p>
<p>The free sample is the whole of Chapter 4, the automations chapter. Here is a taste of what is in it, plus the Zigbee fix from Chapter 2 that most people need first.</p>
<h2 id="the-trap-a-motion-light-that-turns-off-while-you-are-still-there">The trap: a motion light that turns off while you are still there</h2>
<p>The usual motion light has a motion trigger, a "turn on", a fixed two-minute delay, and a "turn off". It works most nights. Then someone stands still in the hallway reading a label and the light goes out on them.</p>
<p>The reason is the automation's <strong>mode</strong>. The default, <code>single</code>, means that when motion fires again while the first run is still waiting out its delay, the second trigger is silently dropped (there is a warning in the log, which nobody reads). The countdown that started on the first motion keeps running, and the light goes off two minutes after the <em>first</em> movement, not the last.</p>
<h2 id="the-fix-restart-and-a-wait-not-a-delay">The fix: <code>restart</code> and a wait, not a delay</h2>
<p>Two changes. Set the mode to <code>restart</code>, so fresh motion cancels the running countdown and starts over. And wait for the sensor to be off <em>for</em> two minutes instead of sleeping for two minutes:</p>
<pre><code>alias: Hallway light on motion
description: On with motion after dark; off 2 min after motion stops
mode: restart
triggers:
  - trigger: state
    entity_id: binary_sensor.hallway_motion
    to: "on"
conditions:
  - condition: state
    entity_id: sun.sun
    state: below_horizon
actions:
  - action: light.turn_on
    target:
      entity_id: light.hallway_ceiling
    data:
      brightness_pct: 60
  - wait_for_trigger:
      - trigger: state
        entity_id: binary_sensor.hallway_motion
        to: "off"
        for: "00:02:00"
  - action: light.turn_off
    target:
      entity_id: light.hallway_ceiling</code></pre>
<p>Every new motion restarts the run, so the light only goes off two minutes after the last movement ends. The <code>for:</code> on the wait also absorbs a sensor that flaps on and off within a second.</p>
<p>Three more rules from the chapter that keep automations readable:</p>
<ul><li>Every <code>state</code> trigger gets a <code>to:</code>. Without it, the automation fires on <em>any</em> change, including attribute updates like brightness or <code>last_seen</code>, and runs twenty times an hour.</li><li>Flappy sensors get <code>for:</code> on the trigger. A door that reports open, closed, open within a second fires three times without it and zero times with <code>for: "00:00:05"</code>.</li><li>When an automation "didn't work", read its <strong>trace</strong> (Settings, Automations &amp; Scenes, the automation, three-dot menu, Traces) before changing anything. It shows which trigger fired, what every condition evaluated to, and what each action did. Guessing is slower than reading.</li></ul>
<h2 id="the-zigbee-fix-that-comes-first">The Zigbee fix that comes first</h2>
<p>Most "the bedroom sensor dropped again" problems are not the sensor. They are the coordinator stick plugged straight into a USB 3.0 port on the server, inside a metal case, next to the access point. USB 3.0 ports and cables radiate noise in the 2.4 GHz band, which is exactly where Zigbee lives.</p>
<p>The fix is a <strong>USB 2.0 extension cable</strong>, one to two metres long, with the stick on the end of it, a metre away from any SSD, hub or access point. If the machine has a USB 2.0 port, use that. This single change fixes more Zigbee networks than any setting. The chapter goes on to the channel choice (Zigbee 15, 20 or 25, between Wi-Fi channels 1, 6 and 11, chosen before pairing anything), why battery sensors never route and a mains plug in each room is the mesh, and pairing devices in the room they will live in.</p>
<h2 id="what-the-book-covers">What the book covers</h2>
<ol><li>A home that works when the internet doesn't: the five-minute WAN unplug test, one naming scheme, areas and labels</li><li>Zigbee that stays up: coordinator choice and placement, channels, ZHA or Zigbee2MQTT, routers, a working Zigbee2MQTT configuration</li><li>Z-Wave without the mystery: 800-series controllers, Z-Wave JS UI, S2 inclusion, node statistics instead of nightly heals</li><li>Automations that mean something: triggers, conditions, modes, the motion light, traces (the free sample chapter)</li><li>Helpers, templates and blueprints: house mode and guest mode, template sensors with defaults, a motion-light blueprint</li><li>Dashboards people actually use: phone, wall tablet and admin; Sections and tiles; conditional cards</li><li>Notifications that don't cry wolf: tags, actionable alerts, one daily battery report, quiet hours, the alert integration</li><li>Updates without breaking things: the monthly routine, backup plus Proxmox snapshot, one layer at a time, three ways back</li></ol>
<p>Plus the Weekend Reliability Checklist and a cheat sheet. PDF and EPUB, with the full automations chapter free below.</p>
<h2 id="checklist">Checklist</h2>
<ul><li>Motion lights on <code>mode: restart</code> with a <code>wait_for_trigger</code>, not a fixed delay</li><li>Every <code>state</code> trigger has a <code>to:</code>; flappy sensors have <code>for:</code></li><li>Coordinator on a USB 2.0 extension cable, away from the case and the access point</li><li>Zigbee on channel 15, 20 or 25, set before pairing</li><li>A mains-powered router in each room between the coordinator and the far sensors</li></ul><p><a href="https://payhip.com/b/eHgh0">Go deeper: Automations That Just Work</a></p>]]></content:encoded></item><item><title>Grocy Fridge Scanner: update your Grocy stock from a photo</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/grocy-fridge-scanner-android-app/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/grocy-fridge-scanner-android-app/</guid>
<pubDate>Mon, 12 Oct 2026 12:30:00 -0400</pubDate><description>Take a photo of your fridge or pantry and on-device AI updates your Grocy inventory. No cloud uploads, no account required.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charleshartmann.grocyfridge.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>grocy</category><category>self-hosted</category><category>home-inventory</category><category>on-device-ai</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charleshartmann.grocyfridge.jpg" alt=""></p><p>Grocy is a self-hosted tool for tracking what is in your kitchen: stock levels, expiration dates, shopping lists. It is a good fit for a homelab, but it has one weak spot, and anyone who has tried it knows what it is. Keeping it accurate means entering items by hand, and once you fall behind, the inventory stops matching the fridge and the whole system quietly loses its value.</p>
<p>Grocy Fridge Scanner goes after that manual step. You take a photo of your fridge or pantry, an AI model running on your phone identifies what is there, and the app updates your Grocy stock.</p>
<h2 id="what-it-does">What it does</h2>
<ul><li><strong>Photo scanning.</strong> Snap the fridge or pantry, and the on-device model identifies the items in the picture.</li><li><strong>Automatic counting.</strong> Stock levels are counted from the photo instead of typed in.</li><li><strong>Expiration tracking.</strong> The app tracks expiration dates so you know what needs using first.</li><li><strong>Direct Grocy integration.</strong> Updates go straight into your Grocy instance, with real-time inventory sync.</li><li><strong>Shopping lists.</strong> It can generate a shopping list from what you have and what you are missing.</li><li><strong>Less waste.</strong> Knowing what is about to expire is the point, and the listing frames the app around reducing food waste.</li></ul>
<h2 id="how-it-fits-into-a-grocy-setup">How it fits into a Grocy setup</h2>
<p>The app sits between your kitchen and the Grocy instance you already run. Instead of opening Grocy and adjusting each product, you take a photo and let the scanner do the identification and counting, then the stock in Grocy is updated for you. The listing's promise is that no manual entry is needed for those updates.</p>
<p>That changes the habit you need to keep the system accurate. Taking a quick photo after a shopping trip, or whenever you open the pantry to plan a meal, is a much smaller ask than typing in every item. A system that is easier to keep up is one that stays accurate, and an accurate Grocy is what makes the expiration tracking and shopping lists worth having.</p>
<h3 id="who-the-listing-has-in-mind">Who the listing has in mind</h3>
<p>The listing names four groups: Grocy users who want automation, anyone tired of manual inventory tracking, families managing household supplies, and minimalists who want to reduce waste. The common thread is that you want the benefits of an inventory without the chore of maintaining one.</p>
<h2 id="privacy">Privacy</h2>
<p>If you self-host Grocy, there is a good chance you chose it partly to keep household data off someone else's servers. The scanner keeps to the same idea. According to the listing:</p>
<ul><li>All processing happens on your device.</li><li>Photos are not uploaded to the cloud.</li><li>Your grocery data stays local.</li><li>No account is required.</li></ul>
<p>Because identification runs on the phone, the photo of the inside of your fridge does not need to go anywhere except into the model on your own device, and the resulting stock changes go to your own Grocy server.</p>
<h2 id="good-to-know">Good to know</h2>
<ul><li><strong>Price:</strong> free to download, with an optional premium upgrade mentioned in the listing.</li><li><strong>Ads:</strong> the app contains ads.</li><li><strong>Category:</strong> Food and Drink, rated Everyone.</li><li><strong>You need Grocy:</strong> the app updates an existing Grocy inventory, so it assumes you already run Grocy or plan to.</li><li><strong>On-device AI:</strong> identification runs locally rather than through a cloud service.</li></ul>
<h2 id="is-it-for-you">Is it for you?</h2>
<ul><li>You run Grocy and your stock levels are out of date more often than not.</li><li>You want expiration dates tracked without logging every item by hand.</li><li>You manage supplies for a household and want a shopping list built from what is actually there.</li><li>You prefer that photos of your kitchen stay on your phone.</li></ul><p><a href="https://play.google.com/store/apps/details?id=com.charleshartmann.grocyfridge">Get Grocy Fridge Scanner on Google Play</a></p>]]></content:encoded></item><item><title>Never delete this stopped Proxmox VM</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/never-delete-a-proxmox-template-base/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/never-delete-a-proxmox-template-base/</guid>
<pubDate>Sun, 11 Oct 2026 12:30:00 -0400</pubDate><description>A Proxmox template is always stopped, so it looks exactly like an abandoned VM. Here is how full and linked clones differ, how to find every guest that depends on a base, and how to retire a template safely.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-16.jpg" type="image/jpeg" length="0"/>
<category>proxmox</category><category>templates</category><category>linked clones</category><category>homelab</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-16.jpg" alt=""></p><p>Templates are one of Proxmox's best features. You build a base image once, and new guests come off it in seconds. They are also one of its quietest traps, because they create dependencies you cannot see in the guest list, and those dependencies bite on the day you tidy up.</p>
<p>This post is about that day: what a template really is to the guests built from it, how to prove whether one is load-bearing, and how to retire one without breaking anything.</p>
<h2 id="full-clones-vs-linked-clones">Full clones vs linked clones</h2>
<p>When you clone a template, Proxmox offers two modes:</p>
<ul><li><strong>Full clone:</strong> a complete, independent copy of the disk. Slower to create, uses more space, depends on nothing.</li><li><strong>Linked clone:</strong> a thin copy that stores only the differences from the template's disk. Instant, tiny, and <strong>permanently tied to the template's base disk</strong>.</li></ul>
<p>That last part is the whole story. A linked clone is not a copy of the template. It is a layer of changes sitting on top of it. Take away the bottom layer and the top layer has nothing to stand on.</p>
<p>You can see the relationship in the disk names. A template's disks are named like <code>base-9000-disk-0</code>, and a linked clone's config refers to that base, like this:</p>
<pre><code>local-lvm:base-9000-disk-0/vm-105-disk-0</code></pre>
<p>Read it left to right: VM 105's disk lives on top of template 9000's base disk.</p>
<h2 id="the-trap-it-s-stopped-so-it-s-junk">The trap: "it's stopped, so it's junk"</h2>
<p>Templates are always stopped. In a list of guests, a template looks exactly like an abandoned VM.</p>
<p>There is a nastier version too. A plain stopped container that was converted to a template, then used as the source for linked clones, looks like any other forgotten container. Nothing about it shouts "other guests need me".</p>
<p>Delete that base, and what happens depends on your storage:</p>
<div class="table"><table><thead><tr><th>Storage</th><th>What happens when you delete the base</th></tr></thead><tbody><tr><td>ZFS and some others</td><td>Proxmox refuses, because the base still has dependents</td></tr><tr><td>File-based (qcow2 on a directory or NFS)</td><td>The clones' backing file can simply vanish, and every linked clone built from it stops booting</td></tr></tbody></table></div>
<p>Either way, you find out at the worst possible moment. The rule of thumb from the book: <strong>never delete a template, or anything named <code>base-*</code>, until you have proven nothing references it.</strong></p>
<h2 id="the-fix-part-1-find-every-dependent">The fix, part 1: find every dependent</h2>
<p>Every guest config lives in <code>/etc/pve</code>, so one grep tells you who depends on a base. For template 9000:</p>
<pre><code># Who uses template 9000's disks?
grep -rl "base-9000-" /etc/pve/nodes/*/qemu-server/ /etc/pve/nodes/*/lxc/</code></pre>
<p>If the grep returns anything other than the template's own config, the template is load-bearing. Leave it, label it, and move on.</p>
<p>To see every template on the cluster in the first place:</p>
<pre><code># All templates on the cluster
grep -l "^template: 1" /etc/pve/nodes/*/qemu-server/*.conf \
  /etc/pve/nodes/*/lxc/*.conf</code></pre>
<h2 id="the-fix-part-2-label-the-load-bearing-guests">The fix, part 2: label the load-bearing guests</h2>
<p>The cheapest fix in the whole chapter is a note and a tag. Write it in the guest's Notes field (the Summary tab), and add a <code>keep</code> tag:</p>
<pre><code>qm set 9000 --tags "template,keep" \
  --description "BASE TEMPLATE. Linked clones depend on this. Do not delete."
pct set 9050 --tags "template,keep"</code></pre>
<p>Tags show up as colored pills in the resource tree. A bright red <code>keep</code> tag stops a lot of 1 a.m. mistakes, and the note tells whoever is cleaning up why the guest is there.</p>
<p>The same idea carries into any cleanup script. The book's backup-before-delete loop skips templates outright before it touches anything:</p>
<pre><code>grep -q "^template: 1" /etc/pve/{lxc,qemu-server}/$id.conf 2&gt;/dev/null &amp;&amp; \
  { echo "SKIP $id: template"; continue; }</code></pre>
<h2 id="the-fix-part-3-untangle-before-you-retire">The fix, part 3: untangle before you retire</h2>
<p>Sometimes you really do want a template gone: it is old, its OS is out of date, and you have built a better one. You can retire it, but only after its linked clones stop needing it. That means turning each linked clone into a full, independent guest first.</p>
<p>The simplest way is a fresh full clone of each linked clone:</p>
<pre><code># VM: full, independent copy of linked clone 105 as 305
qm clone 105 305 --full 1 --name app-server
# Container: same idea
pct clone 205 405 --full 1 --hostname app-ct</code></pre>
<p>Moving a disk to a different storage also writes a full copy:</p>
<pre><code>qm disk move 105 scsi0 other-store --delete 1</code></pre>
<p>For containers, <code>pct move-volume</code> does the same job.</p>
<p>Then work through it in order:</p>
<ol><li>Test the full clone.</li><li>Retire the linked one.</li><li>Rerun the dependents grep for that template.</li><li>When it comes back with nothing but the template's own config, the template is finally safe to remove.</li></ol>
<p>Deleting it is still a cleanup like any other: back it up, verify the archive, then destroy with purge.</p>
<h2 id="long-lived-guests-should-be-full-clones">Long-lived guests should be full clones</h2>
<p>Linked clones are instant and tiny, which makes them tempting. But the book's checklist is clear: long-lived production guests should be <strong>full clones</strong>, so they depend on nothing and a future cleanup cannot pull the floor out from under them.</p>
<p>And because templates stick around, keep them fresh: rebuild or update them on a schedule, with a patched OS and current cloud-init, so new guests do not start life months behind.</p>
<h2 id="checklist">Checklist</h2>
<ul><li>Every template is tagged <code>keep</code>, with a note saying what depends on it</li><li>You have grepped for <code>base-&lt;id&gt;-</code> before deleting anything that might be a base</li><li>Any cleanup script skips guests with <code>template: 1</code> in their config</li><li>Long-lived production guests are full clones, not linked clones</li><li>Before retiring a template: full-clone (or disk-move) each linked clone, test it, retire the linked one, rerun the grep</li><li>Templates are rebuilt or updated on a schedule (patched OS, current cloud-init)</li></ul><p><a href="https://payhip.com/b/mJKnI">Go deeper: Homelab the Right Way</a></p>]]></content:encoded></item><item><title>TrailSage: an offline GPS audio tour guide for your road trips</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/trailsage-ai-road-trip-guide/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/trailsage-ai-road-trip-guide/</guid>
<pubDate>Sun, 11 Oct 2026 12:30:00 -0400</pubDate><description>TrailSage turns a drive into a guided audio tour, with offline maps and AI commentary generated on your phone. No accounts, no cloud.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.trailsage.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>road-trip</category><category>offline</category><category>travel</category><category>on-device-ai</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.trailsage.jpg" alt=""></p><p>Long drives have a way of going quiet in the wrong places. The scenery gets interesting right where the cell signal disappears, and most navigation and tour apps stop being useful at exactly that point. TrailSage is built around the opposite assumption: that you will be somewhere without coverage, and the app should keep working anyway.</p>
<p>It combines offline GPS navigation with an audio tour guide. The commentary is generated by an AI model that runs on the phone itself, so the stories about the places along your route do not depend on a connection or an account.</p>
<h2 id="what-it-does">What it does</h2>
<ul><li><strong>Offline maps and navigation.</strong> You download maps ahead of time, then get turn-by-turn directions and live location tracking without cell service.</li><li><strong>AI audio tours.</strong> An on-device model generates commentary about points of interest along your route, including historical facts and local stories.</li><li><strong>A choice of voices.</strong> The narration comes with multiple voice options.</li><li><strong>Hands-off playback.</strong> Tours can start automatically when you drive, can be paused and resumed, and the narration adjusts to your speed.</li><li><strong>Trip planning.</strong> You can save favorite routes, customize the stops on a tour, share routes with friends, and import GPX files.</li><li><strong>A driving-friendly interface.</strong> The layout is clean, with large buttons and a night mode, and it runs on both phones and tablets.</li></ul>
<h2 id="how-a-trip-with-it-works">How a trip with it works</h2>
<p>The workflow follows from the feature list. Before you leave, while you still have a connection, you download the maps for the area you are heading into. From there the app does not need the internet: navigation, location tracking and the tour commentary all run on the device.</p>
<p>If you already have a route planned in another tool, the GPX import means you do not have to rebuild it. You can adjust which stops the tour covers, save the route for later, or share it with whoever you are travelling with.</p>
<p>Once you start driving, the auto-start option means the tour can begin without you touching the phone, and the speed-adjusted narration is meant to keep the commentary in step with how fast you are moving. If you stop for fuel or food, you pause and pick it back up later.</p>
<h3 id="why-on-device-ai-matters-here">Why on-device AI matters here</h3>
<p>Generating the tour commentary on the phone is what lets the audio guide work in the same dead zones as the offline maps. A cloud-based guide would go silent the moment the signal dropped. With TrailSage, the model is already on the device, so the stories keep coming.</p>
<h2 id="privacy">Privacy</h2>
<p>The listing is direct about this. All of the AI processing happens on the device. There is no cloud component and no account to create. Your routes stay on your phone, and the app states that it does no tracking and no data uploads.</p>
<p>For a travel app, that is a meaningful difference. Your route history is a fairly detailed record of where you go, and here it is not leaving the phone.</p>
<h2 id="good-to-know">Good to know</h2>
<ul><li><strong>Price:</strong> free to download, with an optional premium upgrade mentioned in the listing.</li><li><strong>Ads:</strong> the app contains ads.</li><li><strong>Category:</strong> Travel and Local, rated Everyone.</li><li><strong>Battery:</strong> the app is described as battery-efficient, which matters on a long day of navigation.</li><li><strong>Prepare before you go:</strong> offline maps have to be downloaded before you lose service, so do that at home or at your last stop with a connection.</li></ul>
<h2 id="is-it-for-you">Is it for you?</h2>
<ul><li>You take road trips through areas where cell coverage is patchy or missing.</li><li>You want to learn something about the places you are driving past, without reading a screen.</li><li>You prefer an app that works without an account and keeps your routes on your phone.</li><li>You already plan routes elsewhere and want to bring them in as GPX files.</li><li>You want a night mode and large buttons for use in the car.</li></ul><p><a href="https://play.google.com/store/apps/details?id=com.charles.trailsage">Get TrailSage: AI Road Trip Guide on Google Play</a></p>]]></content:encoded></item><item><title>Ollama AI Chat: use your Ollama server or offline models on Android</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/ollama-ai-chat-android-client/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/ollama-ai-chat-android-client/</guid>
<pubDate>Sun, 11 Oct 2026 12:30:00 -0400</pubDate><description>Ollama AI Chat connects Android to your own Ollama server or runs models like Gemma and Qwen fully on the phone, with optional browser sync.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.ollama.client.play.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>ollama</category><category>self-hosted</category><category>local-llm</category><category>privacy</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.ollama.client.play.jpg" alt=""></p><p>Running a language model at home is a common homelab project now. Ollama makes it straightforward to host models on your own machine, but you still need a decent way to talk to that server when you are not sitting at your desk. Ollama AI Chat is an Android client for exactly that, and it adds a second mode: running smaller models directly on the phone, with no server at all.</p>
<h2 id="two-ways-to-run-a-model">Two ways to run a model</h2>
<p><strong>Remote Ollama.</strong> Point the app at your home or work Ollama server and you get access to whatever models it hosts. You can add multiple servers, switch models per conversation, and watch responses stream in real time.</p>
<p><strong>On-device.</strong> The app can also run models locally through Google's LiteRT-LM runtime. The listing names Gemma 4, Gemma 3, Qwen 3, Qwen 2.5, DeepSeek R1 Distill and Phi-4 Mini, ranging from about 300 MB to about 4 GB depending on capability. You download a model once, and after that chatting works fully offline, with zero network traffic for chat content.</p>
<p>That split is practical. When you are on your home network, the big models on your server are available. When you are somewhere without a connection, a smaller model on the phone still works.</p>
<h2 id="what-it-does">What it does</h2>
<ul><li><strong>Streaming chat</strong> for both remote and on-device models, with history saved locally.</li><li><strong>Thread organization:</strong> pin, archive and label conversations; export or share a whole thread as Markdown.</li><li><strong>Message tools:</strong> copy, share, delete, edit and resend, regenerate, and stop a response mid-stream while keeping what was already written.</li><li><strong>Search</strong> inside a conversation or across all of them, jumping straight to the message.</li><li><strong>Images</strong> for vision-capable remote models, compressed automatically before upload.</li><li><strong>Voice:</strong> speech-to-text input and read-aloud replies.</li><li><strong>Model control</strong> per thread: temperature, top_p, top_k, context window and seed, plus token speed and response time under each reply and full model details (parameters, quantization, template, license).</li></ul>
<h2 id="web-chat-sync">Web Chat Sync</h2>
<p>The newest feature lets you keep chatting from a computer without installing anything on it. You turn on Web Sync in the app's settings and sign in with Google or an email and password. Then you open the web chat page in any desktop or mobile browser, sign in with the same account, and your threads appear.</p>
<p>The interesting part is where the work happens. When you type in the browser, your Android phone receives the message, runs the inference (against your Ollama server or on-device), and streams the answer back to the browser. If the phone is offline, messages queue in the browser and are sent once it reconnects. A presence indicator shows when the phone is online, and the model selector only lists models you have actually installed.</p>
<h2 id="privacy">Privacy</h2>
<p>Conversation history is stored locally on the device in a Room database. On-device inference sends no chat content over the network. If you turn on Web Sync, threads and messages sync to Firebase Realtime Database under your own account, secured by Firebase Auth, so that part does go through a cloud service. The listing also states there is no third-party advertising data collection for chat content.</p>
<h2 id="good-to-know">Good to know</h2>
<ul><li><strong>Price:</strong> free to download. The app contains ads, and the Play listing notes in-app purchases.</li><li><strong>Requirements:</strong> Android 7.0 (API 24) or higher.</li><li><strong>Connection:</strong> remote Ollama and Web Chat Sync need an internet connection; on-device models work offline after the download.</li><li><strong>Storage:</strong> on-device models take roughly 300 MB to 4 GB each, so check your free space first.</li><li><strong>Open source:</strong> released under the MIT license.</li></ul>
<h2 id="is-it-for-you">Is it for you?</h2>
<ul><li>You run an Ollama server and want a proper phone client for it.</li><li>You want a model that still works on the phone with no connection.</li><li>You compare models and care about settings like temperature, context window and token speed.</li><li>You want to continue phone conversations from a browser without installing a desktop app.</li></ul><p><a href="https://play.google.com/store/apps/details?id=com.charles.ollama.client.play">Get Ollama AI Chat on Google Play</a></p>]]></content:encoded></item><item><title>OctoPulse: watch and control OctoPrint from your Android phone</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/octopulse-octoprint-android-app/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/octopulse-octoprint-android-app/</guid>
<pubDate>Sun, 11 Oct 2026 12:30:00 -0400</pubDate><description>OctoPulse finds OctoPrint on your Wi-Fi, pairs in one tap, and puts progress, camera, temps and controls on your phone. Free, no account.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.octopulse.jpg" type="image/jpeg" length="0"/>
<category>android</category><category>3d-printing</category><category>octoprint</category><category>raspberry-pi</category><category>self-hosted</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/app-com.charles.octopulse.jpg" alt=""></p><p>If you run OctoPrint, you know the routine: a print is going, you have walked away, and now you want to know whether the first layer stuck. Opening the web interface on a phone works, but it was not designed for a small screen. OctoPulse is a native Android companion for OctoPrint that puts the things you check most often (progress, temperatures, the camera) one tap away.</p>
<p>OctoPrint is the open-source web interface many people use to run a 3D printer from a Raspberry Pi or another small computer. OctoPulse talks to it over your network. It works with any printer running OctoPrint, including OctoPi on a Raspberry Pi, and the listing names Ender 3, Prusa and Bambu (with OctoPrint) as examples. The rule of thumb it gives is simple: if you can open OctoPrint in your browser, OctoPulse can connect.</p>
<h2 id="what-it-does">What it does</h2>
<ul><li><strong>Finds your printer for you.</strong> It scans your Wi-Fi using mDNS/Bonjour, SSDP and a subnet scan, so you do not type IP addresses. It also verifies that what it found is really OctoPrint.</li><li><strong>Pairs without copying API keys.</strong> It uses OctoPrint's official Application Keys: tap the printer, approve the request in OctoPrint, and it is connected.</li><li><strong>Shows every printer on one dashboard.</strong> Status, progress percentage, time remaining, and nozzle and bed temperatures, with pull to refresh.</li><li><strong>Streams the camera.</strong> MJPEG or snapshot streaming, with flip, rotate and automatic reconnect.</li><li><strong>Gives you real control.</strong> Jog X, Y and Z, home the axes, extrude and retract, set temperatures, run the fan, hit emergency stop, or send G-code from a terminal.</li><li><strong>Previews G-code.</strong> A 2D and 3D layer viewer with pinch-zoom and orbit.</li><li><strong>Manages files.</strong> Browse what is on OctoPrint, see details, start a print or delete a file.</li><li><strong>Sends alerts.</strong> Notifications when a print finishes or fails, and at 25, 50, 75 and 90 percent, with per-printer toggles and background polling. It respects Do Not Disturb.</li></ul>
<h2 id="getting-started">Getting started</h2>
<p>The setup the listing describes is three steps:</p>
<ol><li>Install OctoPulse and join the same Wi-Fi network as your OctoPrint server.</li><li>Tap <strong>Discover Printers on Wi-Fi</strong>, choose your printer, tap <strong>Pair</strong>, and approve the request inside OctoPrint.</li><li>From there you can monitor progress, watch the camera, pause or stop if needed, and start the next print.</li></ol>
<p>For access away from home, OctoPulse does not provide its own cloud relay. Instead you point it at your VPN or reverse proxy URL, and OctoPrint has to be reachable that way.</p>
<h2 id="privacy-and-permissions">Privacy and permissions</h2>
<p>This is where OctoPulse fits a self-hosted setup well. There is no account and no sign-up. Print files, G-code and camera feeds do not go to the developer's servers; the data stays between your phone and your printer. Hosts, API keys and camera URLs are encrypted on the device, with the keys stored using the Android Keystore.</p>
<p>The permissions are limited to what the features need: network and Wi-Fi state, multicast for mDNS discovery, notifications for print alerts, starting at boot to reschedule alerts, vibration, and the advertising ID for ads. It does not request location, camera or contacts. Discovery scans your local subnet only.</p>
<h2 id="good-to-know">Good to know</h2>
<ul><li><strong>Price:</strong> free, with no subscription.</li><li><strong>Ads:</strong> it contains ads, a small banner plus an occasional full-screen ad after setup steps. The listing says ads are never shown during a print.</li><li><strong>Open source:</strong> the app is released under the MIT license.</li><li><strong>Look and feel:</strong> dark theme, haptics and offline handling.</li><li><strong>Not official:</strong> OctoPulse is not affiliated with the OctoPrint project.</li></ul>
<h2 id="is-it-for-you">Is it for you?</h2>
<ul><li>You already run OctoPrint, on a Raspberry Pi or another machine.</li><li>You want progress, temps and the camera on your phone without opening a browser.</li><li>You run more than one printer and want them on a single dashboard.</li><li>You would rather keep printer data on your own network than in someone's cloud.</li><li>You already have a VPN or reverse proxy if you want to check prints remotely.</li></ul><p><a href="https://play.google.com/store/apps/details?id=com.charles.octopulse">Get OctoPulse: OctoPrint Companion on Google Play</a></p>]]></content:encoded></item><item><title>RAID is not a backup: the 3-2-1 rule, honestly</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/raid-is-not-a-backup-3-2-1/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/raid-is-not-a-backup-3-2-1/</guid>
<pubDate>Sat, 10 Oct 2026 12:30:00 -0400</pubDate><description>What actually counts as a copy under the 3-2-1 rule, why a RAID mirror or a snapshot only protects against one kind of failure, and the one-page inventory that exposes the gaps.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-11.jpg" type="image/jpeg" length="0"/>
<category>backups</category><category>3-2-1</category><category>zfs</category><category>proxmox</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-11.jpg" alt=""></p><p>Three copies of anything you care about, on two different kinds of storage, with one of them somewhere else. Everyone has heard the 3-2-1 rule. Far fewer homelabs actually have it, because the rule is easy to say and the details are where it falls apart.</p>
<p>The problem is usually not that people have no backups. It is that some of the copies they are counting are not copies at all.</p>
<h2 id="things-that-feel-like-backups-but-aren-t">Things that feel like backups but aren't</h2>
<p><strong>RAID or a ZFS mirror.</strong> That is one copy on resilient disks. It protects you from a dead drive and from nothing else: not from <code>rm -rf</code>, not from a bad update, not from ransomware, not from the host catching fire. Delete a file and the mirror faithfully deletes it on both disks.</p>
<p><strong>Snapshots.</strong> A ZFS or Proxmox snapshot lives on the same pool as the data. It is a wonderful undo button and a terrible backup. Lose the pool and the snapshots go with it.</p>
<p><strong>A second copy on the same box.</strong> vzdump writing to a different directory on the same host is one disk failure away from losing both copies.</p>
<p><strong>The NAS.</strong> A copy on the NAS is a real second copy. It is still in the same house, on the same power circuit, behind the same door.</p>
<p><strong>The cloud sync folder.</strong> Sync is not backup. Delete the file at home and the cloud deletes it too, sometimes with a short grace period, sometimes not.</p>
<h2 id="what-actually-counts">What actually counts</h2>
<p>A copy counts when it <strong>survives the loss of the thing it was copied from</strong>. That one sentence settles most arguments about what is and isn't a backup.</p>
<p><strong>Two kinds of storage</strong> means two things that do not fail together: local disk and a NAS, local disk and object storage, a Proxmox Backup Server datastore and a tape if you are that kind of person.</p>
<p><strong>Off-site</strong> means a different building. A friend's house across town is off-site. The garage is not.</p>
<p>A shape that satisfies the rule for a typical Proxmox homelab looks like this:</p>
<div class="table"><table><thead><tr><th>Copy</th><th>Where</th><th>Protects against</th></tr></thead><tbody><tr><td>1. The live data</td><td>Proxmox hosts, ZFS mirrors</td><td>A dead disk (and nothing else)</td></tr><tr><td>2. Nightly backups</td><td>Proxmox Backup Server on a separate box, plus vzdump to the NAS for a few guests</td><td>Mistakes, bad updates, a dead host</td></tr><tr><td>3. Off-site</td><td>Encrypted copy in object storage, or a PBS sync to a friend's box</td><td>Fire, theft, flood, ransomware that reaches the NAS</td></tr></tbody></table></div>
<p>Notice that row 1 is in the table. The live data is a copy, it just is not a backup. RAID earns its place by keeping you running through a disk failure, not by protecting your history.</p>
<h2 id="write-the-inventory-first">Write the inventory first</h2>
<p>Before touching a single backup job, write down what you are protecting. It fits on one page and it is one of the most useful documents a homelab can have.</p>
<p>One row per thing, four columns:</p>
<ol><li><strong>What it is</strong> (Home Assistant VM, OPNsense, the photo server, the Proxmox hosts themselves, the NAS data share)</li><li><strong>How it is backed up</strong></li><li><strong>How far back you can go</strong></li><li><strong>How you would restore it</strong></li></ol>
<p>The last column is the one that exposes the holes. It is easy to write "nightly backup" in column two. It is much harder to write a restore path you have actually tried. Any row with "none" in column two, or a restore path you have never tested, is your to-do list.</p>
<p>Keep the table somewhere that survives the homelab: a note on your phone or a printed page. A recovery plan stored only on the server you are recovering is not much of a plan.</p>
<h2 id="decide-what-you-do-not-back-up">Decide what you do not back up</h2>
<p>Backing up everything is how backup storage fills up and jobs start failing. Some things are cheaper to rebuild than to store:</p>
<ul><li>Downloaded ISOs and container templates. Keep a list, not the files.</li><li>Media libraries you can re-rip or re-download, if you are honest about whether you would.</li><li>Scratch VMs and test containers. Tag them <code>scratch</code> so the exclude is obvious.</li><li>Caches: transcodes, thumbnails an app will regenerate, package caches.</li></ul>
<p>Everything <strong>not</strong> on that list gets backed up by default. "By default" is the important phrase: a backup job that protects new guests automatically, rather than a hand-picked list, is what makes it true.</p>
<h2 id="checklist">Checklist</h2>
<ul><li>Inventory written: one row per thing, with how it is backed up, how far back, and the restore path</li><li>Every row has a copy on a second kind of storage and one off-site</li><li>RAID, snapshots and sync folders are not counted as backups anywhere in the table</li><li>A short list of what you deliberately do not back up, and why</li><li>The table lives somewhere that survives the homelab</li></ul>
<p>RAID keeps you running. Backups get you back. You want both, and you want to know which one you are looking at.</p><p><a href="https://payhip.com/b/1iHXQ">Go deeper: Never Lose Your Data</a></p>]]></content:encoded></item><item><title>Proxmox backups that actually work: 3-2-1, step by step</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/proxmox-backups-that-actually-work/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/proxmox-backups-that-actually-work/</guid>
<pubDate>Sat, 10 Oct 2026 12:30:00 -0400</pubDate><description>The whole backup playbook in one walk-through: the 3-2-1 plan, vzdump, Proxmox Backup Server, retention, an off-site copy, restore drills and the alert for jobs that quietly stop.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-14.jpg" type="image/jpeg" length="0"/>
<category>proxmox</category><category>backups</category><category>pbs</category><category>3-2-1</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-14.jpg" alt=""></p><p>Your Proxmox backup job has a schedule, a storage target and a little green check. That check proves one thing: the job wrote some bytes somewhere. It does not prove every guest is covered, that the copy survives a dead host, or that anything actually restores.</p>
<p>This post walks through a complete homelab backup setup in the order you would build it, one layer at a time. Each layer closes a hole the previous one leaves open.</p>
<h2 id="1-the-plan-3-2-1-honestly">1. The plan: 3-2-1, honestly</h2>
<p>Three copies of anything you care about, on two different kinds of storage, with one of them somewhere else.</p>
<p>A copy only counts if it <strong>survives the loss of the thing it was copied from</strong>. That rules out RAID and ZFS mirrors (one copy on resilient disks), snapshots (same pool as the data), a second copy on the same host, and sync folders (delete at home, delete in the cloud). The NAS is a real second copy, but it is still in the same house. Off-site means a different building.</p>
<p>Before touching any job, write a one-page inventory: each thing you run, how it is backed up, how far back you can go, and how you would restore it. The restore column is the one that exposes the gaps.</p>
<h2 id="2-vzdump-the-backup-you-already-have">2. vzdump: the backup you already have</h2>
<p>Every Proxmox install ships with vzdump. Point it at storage that is <strong>not</strong> the host's own disk, such as an NFS share on the NAS:</p>
<pre><code>pvesm add nfs nas-backup --server 192.168.10.40 --export /volume1/proxmox \
  --content backup --options vers=4.1</code></pre>
<p>Then create one job under <strong>Datacenter → Backup → Add</strong> with <strong>Selection mode: All</strong> and your scratch guests under Exclude, so every new guest is protected the day it is created. Snapshot mode, ZSTD compression, a nightly schedule and a notes template like <code>{{guestname}} on {{node}}</code>.</p>
<p>Install the QEMU guest agent in every VM and enable it in the VM's Options. With it, snapshot mode asks the guest to flush its filesystems first, which is the difference between a consistent backup and one that needs fsck on restore.</p>
<h2 id="3-proxmox-backup-server-when-vzdump-isn-t-enough">3. Proxmox Backup Server: when vzdump isn't enough</h2>
<p>PBS adds deduplication, incremental backups, verify jobs and client-side encryption. The trap is running it on the same host, or in a VM on the same host with its datastore on the same pool. It works beautifully until the host dies, and then the backups die with it. Give PBS its own hardware, however modest: an old office PC with one large disk and 4 GB of RAM is plenty for a home datastore.</p>
<p>Connect it with a dedicated <code>backup@pbs</code> user rather than root, and turn on client-side encryption:</p>
<pre><code>pvesm set pbs01 --encryption-key autogen</code></pre>
<p>Every chunk is then encrypted before it leaves the node. <strong>Download that key immediately</strong> and store it somewhere that is not the cluster, such as a password manager or a printed QR code. Without the key, every encrypted backup is unrecoverable. Losing it is the one mistake with no undo.</p>
<p>Running both is a sensible shape: PBS nightly for everything, plus vzdump to the NAS for the few guests you want as single portable files.</p>
<h2 id="4-retention-that-lets-you-sleep">4. Retention that lets you sleep</h2>
<p><code>keep-daily=7</code> does <strong>not</strong> mean "keep seven days". It means "keep the last backup from each of the last seven days that have a backup". If the job failed for a month and then ran once, that one run can satisfy every keep rule at once, and pruning deletes the older history. The rules keep the newest backup per calendar bucket; they never promise coverage of time.</p>
<p>Retention also does not protect you from a bad backup: if the guest was already corrupted, you just have seven copies of the corruption. That is why weekly and monthly buckets matter. Set retention on the storage or the PBS prune job, not per job, check it in the prune simulator, and take a <strong>protected</strong> backup before every risky change so pruning cannot touch it.</p>
<h2 id="5-off-site-the-copy-that-survives-the-house">5. Off-site: the copy that survives the house</h2>
<p>Three realistic options: an encrypted copy in Backblaze B2 via rclone, a PBS sync job to a friend's box over Tailscale, or a USB disk rotated to work. The USB disk is cheap and fast to restore from, but it is not automatic, and that is its problem.</p>
<p>For B2, put an rclone <strong>crypt</strong> remote on top of the bucket with a key scoped to that bucket, sync the dump directory nightly with a bandwidth limit during the day, add a lifecycle rule so hidden versions expire, and run <code>rclone check</code> monthly. Store the two crypt passwords with your PBS key, outside the homelab. And read the current B2 price from Backblaze's own pricing page rather than from any guide, including this one.</p>
<p>The trap here is the off-site copy that was set up once and never checked: an expired app key, a friend's box that was powered off, a bucket that filled up. Off-site is where "no news is good news" does the most damage.</p>
<h2 id="6-restore-testing-the-only-proof">6. Restore testing: the only proof</h2>
<p>"Verified" means different things at each layer. A job reporting OK means it finished writing. <code>zstd -t</code> means the archive is complete and decompresses. A PBS verify job means the chunks still match their hashes. None of them means the guest boots.</p>
<p>Once a quarter, run four drills, each restored to a <strong>new ID with the network unplugged</strong>, then destroyed:</p>
<ol><li>A VM from PBS. Does it boot, and is last night's data there?</li><li>A container from a vzdump archive on the NAS (second tool, second storage).</li><li>A single file from PBS using File Restore. This is the restore you will do most often.</li><li>Something from off-site. This is the only drill that proves the copy that survives the house.</li></ol>
<pre><code>qmrestore pbs01:backup/vm/101/2026-10-08T01:30:00Z 9101 \
  --storage local-zfs --unique 1
qm set 9101 --net0 virtio,bridge=vmbr0,link_down=1
qm start 9101</code></pre>
<p>Write each drill down: date, what, from where, result. A drill nobody wrote down did not happen.</p>
<h2 id="7-alerts-for-the-jobs-that-quietly-stop">7. Alerts for the jobs that quietly stop</h2>
<p>Alerting only on failure has a blind spot: <strong>a job that never runs never fails.</strong> The alert you need is "nothing succeeded recently".</p>
<p>Use the Proxmox notification system (VE 8.1+) for warnings and errors, then add a dead-man's switch: a check URL, from a service like healthchecks.io or a self-hosted Uptime Kuma push monitor, that your job pings on success. vzdump's hook script makes it a few lines in <code>/etc/vzdump.conf</code>. Set the check's period to a day with a grace of a few hours, and a night the job does not run becomes a phone notification by breakfast. Do the same for the rclone sync.</p>
<p>Finally, a weekly backup-age report that lists the newest backup of every guest and flags any older than two days, or any guest with no backup at all. That catches the "selected VMs" trap no matter how it sneaks back in.</p>
<h2 id="the-short-version">The short version</h2>
<ul><li>An inventory with a tested restore path per row</li><li>One vzdump or PBS job in Selection mode <strong>All</strong>, on storage that is not the host</li><li>PBS on its own hardware, encrypted, key saved outside the cluster</li><li>Retention you have actually checked in the simulator</li><li>An off-site copy that is automatic and monitored</li><li>Quarterly restore drills, written down</li><li>A dead-man's switch, so silence becomes an alert</li></ul><p><a href="https://payhip.com/b/1iHXQ">Go deeper: Never Lose Your Data</a></p>]]></content:encoded></item><item><title>Your Proxmox backup job is probably skipping your new VMs</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/proxmox-backup-job-skips-new-vms/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/proxmox-backup-job-skips-new-vms/</guid>
<pubDate>Sat, 10 Oct 2026 12:30:00 -0400</pubDate><description>A backup job built for a hand-picked list of guests silently misses every VM you create afterwards. Here is how to switch to &quot;All&quot; with excludes, and the other checks that make the green tick mean something.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-10.jpg" type="image/jpeg" length="0"/>
<category>proxmox</category><category>backups</category><category>vzdump</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-10.jpg" alt=""></p><p>Open your Proxmox web UI, click your backup storage and sort by date. Is there a backup of every guest you care about from the last 48 hours? For a lot of homelabs the honest answer is "most of them", and "most" is how data gets lost.</p>
<p>A backup job in the Datacenter view looks reassuring. It has a schedule, a storage target and a little green check. That check proves one thing: the job wrote some bytes somewhere. It does not prove that every guest you care about is in there.</p>
<h2 id="the-trap-a-job-built-for-selected-vms">The trap: a job built for "selected VMs"</h2>
<p>The most common way this goes wrong is the selection mode. Somebody (often past you) created the job for "selected VMs" when the homelab had five guests. Every guest built after that day is unprotected, and the job keeps reporting success every night, because the guests it knows about are fine.</p>
<p>Nothing errors. Nothing emails you. The list of protected guests and the list of running guests just drift apart, one new container at a time.</p>
<h2 id="the-fix-one-job-that-covers-everything">The fix: one job that covers everything</h2>
<p>Make your main job cover all guests, and exclude the few you truly do not want. New guests are protected the day they are created. If you only make one change after reading this, make this one.</p>
<p>In the UI: <strong>Datacenter → Backup → Add</strong>, then:</p>
<ul><li><strong>Selection mode:</strong> All, with scratch and test guests under Exclude.</li><li><strong>Mode:</strong> Snapshot for running guests. The guest keeps running. Stopped guests are backed up too; Proxmox just reads their disks, and the forgotten stopped VM is often the one you will want back.</li><li><strong>Compression:</strong> ZSTD. Fast and small.</li><li><strong>Schedule:</strong> nightly, at a time nobody is using anything, for example 02:30.</li><li><strong>Retention:</strong> "use storage defaults", so the numbers live in one place.</li><li><strong>Notes template:</strong> <code>{{guestname}} on {{node}}</code>, so the backup list is readable when a VM ID means nothing to you at 2 AM.</li></ul>
<p>The same job from the command line, if you like to keep config in version control:</p>
<pre><code>pvesh create /cluster/backup \
  --schedule "02:30" \
  --all 1 \
  --storage nas-backup \
  --mode snapshot \
  --compress zstd \
  --notes-template "{{guestname}} on {{node}}"</code></pre>
<p>Need to leave out a scratch VM? Use <code>--exclude 9001,9002</code> instead of switching back to selected guests. <strong>An exclude list fails safe; an include list fails dangerous.</strong> If you forget to update an exclude list, you back up one guest too many. If you forget to update an include list, you lose a guest.</p>
<h2 id="three-other-things-hiding-behind-the-green-check">Three other things hiding behind the green check</h2>
<p>The selection mode is the big one, but it is not the only way a "working" job quietly stops protecting you.</p>
<h3 id="1-the-storage-silently-isn-t-there">1. The storage silently isn't there</h3>
<p>If your NAS is added as a plain Directory storage on a path you mounted yourself, and that mount fails at boot, Proxmox will happily write backups into the empty folder on your root disk. A night or two later the host's root filesystem is full and things break in confusing ways.</p>
<p>Either use the native NFS storage type, which lets Proxmox manage the mount and mark the storage offline if it fails, or tell Proxmox the path must be a mountpoint:</p>
<pre><code>pvesm set nas-backup --is_mountpoint yes</code></pre>
<p>And check before you trust it:</p>
<pre><code>pvesm status                 # every storage should say "active"
df -h /mnt/pve/nas-backup    # should show the NAS size, not the root disk</code></pre>
<h3 id="2-nobody-gets-told">2. Nobody gets told</h3>
<p>By default, failures go to the root mailbox on the host, which nobody reads. Proxmox VE 8.1 and later has a proper notification system under <strong>Datacenter → Notifications</strong>. Add a target you actually read, point backup events at it, and set the job's notification mode to use it. Then test it on purpose:</p>
<pre><code>vzdump 101 --storage does-not-exist</code></pre>
<p>If no alert arrives, fix that before anything else. A weekly "all good" message is worth having too: silence is ambiguous, and a success message that suddenly stops arriving is a signal.</p>
<h3 id="3-nothing-gets-pruned">3. Nothing gets pruned</h3>
<p>Without retention, the backup share fills up and every job after that fails. Set retention on the storage itself so every job that writes there inherits it:</p>
<pre><code>pvesm set nas-backup \
  --prune-backups keep-last=2,keep-daily=7,keep-weekly=4,keep-monthly=3</code></pre>
<p>Those numbers do not mean what most people think they mean. That is a post of its own.</p>
<h2 id="prove-it-a-backup-you-haven-t-restored-is-a-rumor">Prove it: a backup you haven't restored is a rumor</h2>
<p>A green check says the job finished writing. A quick integrity check says the archive is readable end to end:</p>
<pre><code>zstd -t /mnt/pve/nas-backup/dump/vzdump-qemu-101-*.vma.zst</code></pre>
<p>Only a restore proves the guest actually comes back. Restore to a spare ID, with the network disconnected so it does not fight the original for an IP, boot it, look around, then destroy the test copy:</p>
<pre><code>qmrestore /mnt/pve/nas-backup/dump/vzdump-qemu-101-&lt;date&gt;.vma.zst 9101 \
  --storage local-zfs --unique 1</code></pre>
<p><code>--unique</code> gives the restored VM fresh MAC addresses so it can sit next to the original.</p>
<h2 id="checklist">Checklist</h2>
<ul><li>One backup job set to <strong>All</strong> guests (with excludes), not a hand-picked list</li><li>Backup storage is native NFS or PBS, or has <code>is_mountpoint</code> set</li><li>Retention set on the storage</li><li>Failure notifications reach somewhere you actually read, and you have tested them</li><li>The newest backup of every guest is less than 48 hours old</li><li>One test restore done, to a new ID, and written down with the date</li></ul>
<p>Five minutes in the Datacenter view now is a lot cheaper than finding out during a restore that the VM you need was never in the job.</p><p><a href="https://payhip.com/b/1iHXQ">Go deeper: Never Lose Your Data</a></p>]]></content:encoded></item><item><title>The one wire that can fry your ESP32 tank monitor</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/one-wire-that-can-fry-your-esp32/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/one-wire-that-can-fry-your-esp32/</guid>
<pubDate>Sat, 10 Oct 2026 12:30:00 -0400</pubDate><description>A turbidity sensor runs on 5 V and outputs up to about 4.5 V, but an ESP32-C3&#x27;s pins only take 3.3 V. Here is why it needs a voltage divider, how to wire it, and the checks to do before you plug in USB.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-13.jpg" type="image/jpeg" length="0"/>
<category>esp32</category><category>turtle tank</category><category>sensors</category><category>diy</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-13.jpg" alt=""></p><p>A water-quality monitor for an aquarium or turtle tank is a great first ESP32 project: a temperature probe, a TDS probe and a turbidity (water clarity) sensor, all reporting into Home Assistant. Two of those three sensors are easy. The third one has a wire that can kill the board if it goes in the wrong place.</p>
<p>This walkthrough uses the Seeed XIAO ESP32-C3, the board used in the Smart Turtle Tank build, but the rule applies to any 3.3 V microcontroller.</p>
<h2 id="the-rule-that-keeps-the-board-alive">The rule that keeps the board alive</h2>
<p>Two voltages come out of the XIAO:</p>
<ul><li><strong>5V</strong> is the USB supply passed straight through.</li><li><strong>3V3</strong> is regulated down for the chip.</li></ul>
<p>The chip's pins can only take <strong>3.3 V on their inputs</strong>. So the rule is simple: a sensor's <em>power</em> can come from 5V if it needs it, but anything that goes back <em>into</em> a D pin must be at or under 3.3 V.</p>
<p>The DS18B20 temperature probe and the TDS board both run happily on 3.3 V and produce signals under 3.3 V. The turbidity board is the odd one out: it needs 5 V to run and its output can reach about <strong>4.5 V</strong>. That is the wire that can fry your board, and it is why the turbidity sensor is the one with a voltage divider between it and the XIAO.</p>
<p>A common mistake is plugging a sensor into 5V "because it is more power". The TDS board and the temperature adapter are powered from 3.3 V precisely so their signal pins stay under the XIAO's limit. More volts in means more volts out.</p>
<h2 id="what-the-divider-does">What the divider does</h2>
<p>A voltage divider is two resistors in series. The output is taken from across the smaller one, so the voltage is split in a fixed ratio. The divider module used in this build divides by 5, so a 4.5 V signal comes out at 0.9 V: safe for the pin.</p>
<p>The trade-off is detail. The XIAO reads analog accurately only up to about 2.5 V, and 0.9 V uses about a third of that range. For more resolution you can swap in two 10 kΩ resistors as a ÷2 divider, which keeps the output under 2.25 V. Either way, you multiply the reading by the ratio (5 or 2) in code.</p>
<p>The divider module's labels confuse almost everyone the first time:</p>
<div class="table"><table><thead><tr><th>Label on the module</th><th>What it actually is</th></tr></thead><tbody><tr><td>VCC screw terminal</td><td>The input (IN+)</td></tr><tr><td>GND screw terminal</td><td>IN−</td></tr><tr><td>S pin</td><td>The divided output</td></tr><tr><td>− pin</td><td>Ground</td></tr></tbody></table></div>
<h2 id="wiring-the-turbidity-sensor">Wiring the turbidity sensor</h2>
<p>Keep the USB-C cable <strong>unplugged</strong> the whole time you wire. It goes in last.</p>
<p><strong>Part 1: power the sensor and feed its signal into the divider</strong></p>
<ol><li>Slide the switch on the turbidity board to <strong>A</strong> (analog), not D.</li><li>Plug the included 3-wire cable into the board, and connect the probe.</li><li>Red jumper from the XIAO's <strong>5V</strong> pin to the cable's <strong>V</strong> pin. This is the only 5V wire in the build, and it skips the breadboard power strip.</li><li>Cable's <strong>G</strong> wire into the ground (−) rail.</li><li>Cable's <strong>S</strong> wire into the divider's <strong>VCC</strong> screw terminal (the input). Tighten it and give it a gentle tug.</li></ol>
<p><strong>Part 2: ground the divider and send its safe signal to D2</strong></p>
<ol><li>Ground jumper from the − rail to the divider's <strong>GND</strong> terminal.</li><li>Ground jumper from the − rail to the divider's <strong>−</strong> pin.</li><li>Signal jumper from the divider's <strong>S</strong> pin to the XIAO's <strong>D2</strong>.</li><li>Leave the divider's <strong>+</strong> pin empty.</li></ol>
<p>Only the clear lower part of the probe goes in the water. The top of the probe and every board stay dry.</p>
<p><strong>Never plug the turbidity S pin straight into the XIAO.</strong></p>
<h2 id="check-everything-before-you-power-up">Check everything before you power up</h2>
<p>With the USB still unplugged, go through this list:</p>
<ul><li>The turbidity switch is on <strong>A</strong>.</li><li>The 3V3 (+) rail has three wires: XIAO 3V3, temperature adapter VCC, TDS +. <strong>Nothing from 5V.</strong></li><li>Only <strong>one</strong> wire on 5V, going to the turbidity board's V.</li><li>Turbidity S goes to the divider, not to the XIAO. D2 connects to the divider's S pin.</li><li>Signals: D4 to the temperature adapter's DAT, D1 to the TDS board's A, D2 to the divider's S.</li><li>No bare metal touching anything it should not. Every board is dry and above the water line.</li><li>Each cable hangs in a small U-shaped drip loop below the electronics, so water runs off the bottom of the loop instead of into the boards.</li></ul>
<p>Then plug in USB. Nothing should get warm and nothing should smell; if it does, unplug right away and recheck the 5V and GND wires.</p>
<p>Another common mistake: plugging the USB in "just to see the light" halfway through wiring. Every wire you add after that goes onto a live board, and a jumper that brushes 5V against a signal pin on its way in can take out the whole board. USB last, every time.</p>
<h2 id="two-readings-that-tell-you-something-is-wrong">Two readings that tell you something is wrong</h2>
<p>Once firmware is loaded, temperature should show a sensible number, TDS reads near zero in air and a steady number in the tank, and turbidity reads higher in clear water and drops when the water gets cloudy.</p>
<p>Two problems catch almost everyone:</p>
<ul><li><strong>The temperature reads 85 °C.</strong> That is the DS18B20's power-on value, which means it never got a successful read. It is usually a loose DAT wire.</li><li><strong>Turbidity is flat no matter what the water does.</strong> The board's switch was left on D (digital) instead of A.</li></ul>
<p>Get the one 5V wire right, put the divider in the turbidity signal path, and plug in USB last. The rest of the build is forgiving.</p><p><a href="https://payhip.com/b/k0nzS">Go deeper: Smart Turtle Tank</a></p>]]></content:encoded></item><item><title>keep-daily=7 doesn&#x27;t mean 7 days of backups</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/keep-daily-7-does-not-mean-7-days/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/keep-daily-7-does-not-mean-7-days/</guid>
<pubDate>Sat, 10 Oct 2026 12:30:00 -0400</pubDate><description>Proxmox and PBS retention keeps the newest backup in each calendar bucket, not a span of days. How a month of failed jobs can prune away your history, how the keep-* rules combine, and how to set retention in one place.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-15.jpg" type="image/jpeg" length="0"/>
<category>proxmox</category><category>backups</category><category>retention</category><category>pbs</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-15.jpg" alt=""></p><p>Retention answers one question: how far back can you go? Most homelabs set it once, glance at <code>keep-daily=7</code>, and assume they have a week of history. That assumption is the trap.</p>
<p>The answer to "how far back" should come from your inventory of what you protect, not from how much disk you happen to have. And it starts with reading the keep-* options correctly.</p>
<h2 id="the-trap-buckets-not-days">The trap: buckets, not days</h2>
<p><code>keep-daily=7</code> does not mean "keep seven days." It means "keep the last backup from each of the last seven days <strong>that have a backup</strong>."</p>
<p>That last part is the whole problem. Picture a job that fails quietly for a month and then runs once. That one good run satisfies <code>keep-daily</code>, <code>keep-weekly</code> and <code>keep-monthly</code> all at once, and pruning happily deletes the older backups. The rules keep the most recent backup for each calendar bucket; they never promise coverage of time.</p>
<p>So a retention policy that looks like three months of history on paper can collapse to a single backup, with no error anywhere, if the jobs stop running.</p>
<h2 id="the-second-trap-retention-doesn-t-fix-a-bad-backup">The second trap: retention doesn't fix a bad backup</h2>
<p>Retention does not protect you from a backup that was bad when it was taken. If the guest was already corrupted when the backup ran, you now have seven pristine copies of corruption.</p>
<p>Retention is about reaching back past the moment something went wrong. That is why the weekly and monthly buckets matter more than the daily ones: a slow corruption can take weeks to notice, and by then the dailies have all rolled over.</p>
<h2 id="how-the-rules-combine">How the rules combine</h2>
<p>Each rule runs independently, and a backup survives if <strong>any</strong> rule claims it. That means you can tune the depth per type of guest instead of using one set of numbers for everything. The book's combination looks like this:</p>
<div class="table"><table><thead><tr><th>Guest type</th><th>keep-last</th><th>daily</th><th>weekly</th><th>monthly</th><th>Why</th></tr></thead><tbody><tr><td>Databases, Home Assistant, Immich</td><td>2</td><td>14</td><td>8</td><td>6</td><td>A slow corruption can take weeks to notice</td></tr><tr><td>Ordinary services</td><td>2</td><td>7</td><td>4</td><td>3</td><td>Mistakes get noticed inside a week</td></tr><tr><td>OPNsense</td><td>2</td><td>0</td><td>8</td><td>6</td><td>It barely changes; config.xml covers the rest</td></tr><tr><td>vzdump copies on the NAS</td><td>1</td><td>0</td><td>4</td><td>2</td><td>Full copies are big; PBS has the depth</td></tr></tbody></table></div>
<p><strong>keep-last</strong> is the safety margin. It protects the newest backups even if they fall on the same day as a pruned one, and it covers the "you just took a backup before an upgrade" case.</p>
<p>Before you commit to any numbers, open the <strong>Prune Simulator</strong> in the Proxmox Backup Server docs (search for "Prune Simulator") and check what actually survives on a calendar. Proxmox's own retention example in the vzdump docs walks through the same thing and is worth reading once.</p>
<h2 id="the-fix-set-it-in-one-place">The fix: set it in one place</h2>
<p>Retention belongs on the storage, not on each job. For a vzdump storage such as an NFS share, set it on the storage so every job that writes there inherits it:</p>
<pre><code>pvesm set nas-backup \
  --prune-backups keep-last=2,keep-daily=7,keep-weekly=4,keep-monthly=3</code></pre>
<p>For Proxmox Backup Server, retention lives in the datastore's <strong>prune job</strong>, and you can give different namespaces or groups different rules. Set the same keep-* numbers there that you chose for each guest type.</p>
<p>On the backup jobs themselves, leave Retention on <strong>"use storage defaults"</strong>. Setting retention on a job as well gives you two sets of rules fighting each other.</p>
<p>On PBS, remember that pruning only unlinks the index. <strong>Garbage collection</strong> is what actually frees chunks, and only chunks older than a grace period (24 hours and 5 minutes by default). Schedule GC daily and expect free space to lag pruning by a day.</p>
<h2 id="changing-the-numbers-later">Changing the numbers later</h2>
<p>Changing retention is safe in one direction only. <strong>Raising</strong> the numbers keeps more. <strong>Lowering</strong> them deletes on the next prune, with no undo.</p>
<p>If you are short on space, run the change through the simulator first, and if something has to give, shorten the vzdump retention, not the PBS one.</p>
<h2 id="protected-backups-for-risky-changes">Protected backups for risky changes</h2>
<p>Before any change you might regret, such as a major Home Assistant upgrade, a Proxmox major version, or migrating a database, take a manual backup and mark it protected. A protected backup survives every prune until you remove the flag.</p>
<p>In the Proxmox GUI, select the backup and tick <strong>Protected</strong>. On the command line:</p>
<pre><code>V=pbs01:backup/vm/101/2026-10-04T01:30:00Z
pvesh set /nodes/pve1/storage/pbs01/content/$V --protected 1</code></pre>
<p>Make a habit of clearing them after a month. Protected backups do not count toward keep-*, so they pile up.</p>
<h2 id="how-much-space-to-plan-for">How much space to plan for</h2>
<p>Real numbers depend on your data, but the book gives a sizing rule of thumb for a new PBS disk: the first backup of a guest is roughly its used disk space after compression, and each further day adds about the daily change. For the book's mix of guests, a year of PBS history fits in about twice the live data.</p>
<p>For vzdump full copies, multiply the guest's used size by the number of copies you keep, then add a third for growth.</p>
<h2 id="checklist-retention">Checklist: retention</h2>
<ul><li>Keep-* numbers chosen per guest type from your inventory, not from free space</li><li>Checked in the prune simulator that a month of failed jobs cannot wipe your history</li><li>Retention set on the storage or the PBS prune job, with jobs on "use storage defaults"</li><li>PBS garbage collection scheduled daily</li><li>A protected backup taken before every risky change, and cleared a month later</li><li>Backup storage sized with room for a year of growth</li></ul>
<p>Retention only keeps history if backups keep landing. A month of silent failures is exactly what turns <code>keep-daily=7</code> into a single copy, so pair these numbers with an alert for jobs that stop running.</p><p><a href="https://payhip.com/b/1iHXQ">Go deeper: Never Lose Your Data</a></p>]]></content:encoded></item><item><title>Delete old Proxmox VMs without regret</title><link>https://chartmann1590.github.io/homelab-the-right-way/posts/delete-old-proxmox-vms-without-regret/</link><guid isPermaLink="true">https://chartmann1590.github.io/homelab-the-right-way/posts/delete-old-proxmox-vms-without-regret/</guid>
<pubDate>Sat, 10 Oct 2026 12:30:00 -0400</pubDate><description>The backup, verify, then destroy-with-purge routine for clearing out stopped guests safely, including the linked-clone check that stops you deleting something a running VM depends on.</description>
<enclosure url="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-12.jpg" type="image/jpeg" length="0"/>
<category>proxmox</category><category>cleanup</category><category>vzdump</category><category>backups</category>
<content:encoded><![CDATA[<p><img src="https://chartmann1590.github.io/homelab-the-right-way/assets/img/og/2026-10-12.jpg" alt=""></p><p>Every homelab accumulates guests. The container you built to try something, the VM from a tutorial, the old version of a service you migrated away from. Eventually most of the guest list is grey stopped icons, and the nightly backup job is dutifully copying all of them.</p>
<p>Cleaning house is easy to put off because deleting things is scary. The way to make it not scary is a routine where every single guest you delete is recoverable: <strong>back it up, verify the backup, then destroy with purge.</strong></p>
<h2 id="the-trap-stopped-is-not-the-same-as-unused">The trap: stopped is not the same as unused</h2>
<p>A template is always stopped. A container that was turned into the base of linked clones is always stopped. Delete the base and the clones keep running until the next reboot, and then they do not.</p>
<p>So before you delete anything, find out what is actually unused.</p>
<h2 id="step-0-check-for-linked-clones">Step 0: check for linked clones</h2>
<p>For every stopped guest, check whether any config references its disks as a base:</p>
<pre><code>for id in $(pct list | awk 'NR&gt;1 &amp;&amp; $2=="stopped"{print $1}') \
          $(qm list | awk 'NR&gt;1 &amp;&amp; $3=="stopped"{print $1}'); do
  if grep -lq "base-${id}-" /etc/pve/nodes/*/*/*.conf 2&gt;/dev/null; then
    echo "KEEP $id: base for linked clones"
  else
    echo "candidate $id"
  fi
done</code></pre>
<p>Tag anything marked KEEP as <code>keep</code> in the GUI and it is out of the discussion. Then walk the candidates with a human eye: the name, the notes, the last-modified date of its config. If you are not sure what a guest is, start it, look, stop it. Ten minutes per mystery guest is cheaper than one wrong destroy.</p>
<h2 id="step-1-back-it-up-in-stop-mode">Step 1: back it up, in stop mode</h2>
<p>A stopped guest does not need snapshot mode, and stop mode gives the most consistent archive. Write it to your backup storage with vzdump so the result is one portable file, and give it a note that will mean something in a year:</p>
<pre><code>vzdump 212 --storage nas-backup --mode stop --compress zstd --bwlimit 60000 \
  --notes-template 'DELETED {{guestname}} ({{vmid}}) cleanup 2026-10'</code></pre>
<p>Watch the backup storage's free space as you go. Work in order of "would be saddest to lose", and if the share gets tight, prune old nightly backups to make room before carrying on. The bandwidth limit keeps your busy guests from lagging while the dumps run; it costs a few minutes per guest.</p>
<h2 id="step-2-verify-the-archive">Step 2: verify the archive</h2>
<p>A backup you have not at least integrity-checked is a rumour. For a container archive:</p>
<pre><code>zstd -t /mnt/pve/nas-backup/dump/vzdump-lxc-212-2026_10_09-21_14_03.tar.zst</code></pre>
<p>VM archives are <code>.vma.zst</code> files and the same <code>zstd -t</code> works. This proves the file is whole and decompresses. It does <strong>not</strong> prove the guest boots. For anything you would truly mourn, go further: restore it to a new ID and start it with the network unplugged.</p>
<p>Then mark the archive protected, so the nightly prune cannot eat it:</p>
<pre><code>F=vzdump-lxc-212-2026_10_09-21_14_03.tar.zst
pvesh set /nodes/pve1/storage/nas-backup/content/nas-backup:backup/$F \
  --protected 1</code></pre>
<h2 id="step-3-destroy-with-purge">Step 3: destroy, with purge</h2>
<pre><code>pct destroy 212 --purge
# or
qm destroy 212 --purge</code></pre>
<p><code>--purge</code> removes the guest from every backup job, the replication config and HA. Without it, the nightly backup job keeps a reference to a guest that no longer exists and fails with an error about that ID every night until you edit the job by hand.</p>
<h2 id="the-loop">The loop</h2>
<p>For more than a handful of guests, put the IDs in a file (one per line, after the human pass) and let a script do the three steps:</p>
<pre><code>#!/bin/bash
set -u
STORE=nas-backup
DUMP=/mnt/pve/$STORE/dump
NOTE="DELETED {{guestname}} ({{vmid}}) cleanup $(date +%Y-%m)"
while read -r id; do
  if pct config "$id" &gt;/dev/null 2&gt;&amp;1; then kind=lxc; cmd=pct
  else kind=qemu; cmd=qm; fi
  echo "== $id ($kind)"
  vzdump "$id" --storage $STORE --mode stop --compress zstd \
    --bwlimit 60000 --notes-template "$NOTE" \
    || { echo "backup FAILED $id"; continue; }
  f=$(ls -t $DUMP/vzdump-$kind-$id-*.zst | head -1)
  zstd -t "$f" || { echo "verify FAILED $id"; continue; }
  vol="$STORE:backup/$(basename "$f")"
  pvesh set /nodes/$(hostname)/storage/$STORE/content/$vol --protected 1
  $cmd destroy "$id" --purge || echo "destroy FAILED $id (retry later)"
done &lt; /root/cleanup-ids.txt</code></pre>
<p>Every <code>continue</code> means a failure leaves the guest in place. <strong>Nothing is ever destroyed without a verified archive ahead of it.</strong></p>
<h2 id="two-gotchas">Two gotchas</h2>
<p><strong>Cluster quorum.</strong> On a two-node cluster, if one node drops off, destroys on the cluster fail with a quorum error while it is out. Re-run the failed IDs once the cluster is quorate again. The proper fix is a QDevice, so the cluster has a third vote.</p>
<p><strong>Leftover locks.</strong> Proxmox will not back up or destroy a guest while a lock from an earlier interrupted job is set. <code>pct unlock 212</code> or <code>qm unlock 212</code> clears it, once you have checked nothing is actually running against the guest.</p>
<h2 id="afterwards-the-orphans">Afterwards: the orphans</h2>
<p>With the guests gone, there is a second layer of junk:</p>
<ul><li><strong>Orphan disks:</strong> entries in <code>pvesm list local-zfs</code> that no config references. Compare against <code>grep -h 'local-zfs:' /etc/pve/nodes/*/*/*.conf</code>. Back each one up as a raw image before removing it, same rule.</li><li><strong>Old ISOs and container templates</strong> in <code>local</code>. They download again in minutes.</li><li><strong>Year-old snapshots</strong> on running guests (<code>qm listsnapshot &lt;id&gt;</code>, <code>pct listsnapshot &lt;id&gt;</code>). A snapshot that old is a backup you forgot, on the same disk as the data, slowing every write.</li></ul>
<h2 id="checklist">Checklist</h2>
<ul><li>Every stopped guest checked for linked clones (<code>base-&lt;id&gt;-</code> in any config); templates tagged keep</li><li>A stop-mode vzdump of each candidate with a "DELETED" note, bandwidth-limited</li><li><code>zstd -t</code> on every archive, and the archive marked protected</li><li><code>destroy --purge</code>, never plain destroy</li><li>Failed steps leave the guest in place</li><li>Orphan disks, old ISOs and year-old snapshots cleaned in a second pass</li></ul><p><a href="https://payhip.com/b/1iHXQ">Go deeper: Never Lose Your Data</a></p>]]></content:encoded></item>
</channel></rss>
