<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Charles Hartmann</title>
    <description>The latest articles on DEV Community by Charles Hartmann (@charleshartmann).</description>
    <link>https://dev.to/charleshartmann</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4174346%2Ff67011c1-51d9-4fa5-8db8-68b5b264b12b.png</url>
      <title>DEV Community: Charles Hartmann</title>
      <link>https://dev.to/charleshartmann</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/charleshartmann"/>
    <language>en</language>
    <item>
      <title>ESP32-C3 water monitor for a turtle tank: temperature, TDS and clarity, straight into Home Assistant with ESPHome</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Sat, 10 Oct 2026 01:33:37 +0000</pubDate>
      <link>https://dev.to/charleshartmann/esp32-c3-water-monitor-for-a-turtle-tank-temperature-tds-and-clarity-straight-into-home-f43</link>
      <guid>https://dev.to/charleshartmann/esp32-c3-water-monitor-for-a-turtle-tank-temperature-tds-and-clarity-straight-into-home-f43</guid>
      <description>&lt;p&gt;I keep a red-eared slider named Bobuerto. The one number that actually matters in his tank is water temperature, and the two things I kept guessing at were "is it time for a water change" and "why is the water suddenly cloudy". So I built a small water monitor around a Seeed XIAO ESP32-C3 with three cheap probes. No soldering, one jumper-wire kit, and the whole thing runs off a phone charger.&lt;/p&gt;

&lt;p&gt;This post is the build: the parts, the wiring (with the one gotcha that fries boards), the firmware shape, and an ESPHome YAML that drops all three readings straight into Home Assistant with no code.&lt;/p&gt;

&lt;p&gt;Honesty up front: the wiring and firmware are my build; the ESPHome route is written from the HA docs against the same pin map, not from a second build on my tank. Treat the numbers as starting points and check them on your board.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the three probes actually tell you
&lt;/h2&gt;

&lt;p&gt;These are not a test kit, and I don't want you to think they are.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;Measures&lt;/th&gt;
&lt;th&gt;What it means for turtle water&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DS18B20&lt;/td&gt;
&lt;td&gt;Water temperature, ±0.5 °C&lt;/td&gt;
&lt;td&gt;Whether the heater is doing its job. The one reading that is a real alarm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TDS&lt;/td&gt;
&lt;td&gt;Total dissolved solids (conductivity, in ppm)&lt;/td&gt;
&lt;td&gt;How much has built up since the last water change. Rises as waste dissolves, drops when you change water. A trend meter, not an ammonia test&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Turbidity&lt;/td&gt;
&lt;td&gt;How much light gets through the water&lt;/td&gt;
&lt;td&gt;Cloudiness: a clogged filter, a bacterial bloom, a feeding that went everywhere. Also a trend meter&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the sensor answers: is the water warm enough, is it time for a water change, and did something just go cloudy. Your liquid test kit still answers "is the water safe".&lt;/p&gt;

&lt;h2&gt;
  
  
  Parts
&lt;/h2&gt;

&lt;p&gt;Everything is a plug-together module.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Seeed Studio XIAO ESP32C3&lt;/strong&gt; (get the pre-soldered one). Native USB-C, Wi-Fi, and two analog pins on ADC1 that keep working while Wi-Fi is on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DS18B20 waterproof temperature probe kit&lt;/strong&gt; (BOJACK). The kit's little adapter board already has the 4.7 kΩ pull-up, so nothing to add.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyestudio TDS Meter V1&lt;/strong&gt; module + probe, with its 3-pin cable. Runs on 3.3 V.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyestudio Turbidity Sensor V1&lt;/strong&gt; module + probe. Has an analog/digital switch; use analog.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HiLetgo 0–25 V voltage sensor module.&lt;/strong&gt; This is just a prebuilt 5:1 resistor divider with screw terminals. It is the one part that needs explaining (below).&lt;/li&gt;
&lt;li&gt;Dupont jumper wires (you need M-M, M-F and F-F), and a 400-point breadboard used only as a power strip.&lt;/li&gt;
&lt;li&gt;A USB-C data cable and any 5 V phone charger.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The rule that keeps the XIAO alive
&lt;/h2&gt;

&lt;p&gt;Two voltages come out of the XIAO: &lt;strong&gt;5V&lt;/strong&gt; (USB passed straight through) and &lt;strong&gt;3V3&lt;/strong&gt; (regulated for the chip). The chip's pins can only take &lt;strong&gt;3.3 V on their inputs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The temperature adapter and the TDS board both run happily on 3.3 V and produce signals under 3.3 V. The turbidity board needs 5 V to run and its output can reach about &lt;strong&gt;4.5 V&lt;/strong&gt;. That is why it is the only sensor with a divider between it and the XIAO, and the only one powered from the 5V pin.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Don't plug a sensor into 5V "because it is more power". More volts in means more volts out, and a 4.5 V signal into a D pin is the board gone.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Wiring
&lt;/h2&gt;

&lt;p&gt;Keep the USB cable unplugged the whole time you wire. It goes in last.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyom9sadqt2a416ycfm3y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyom9sadqt2a416ycfm3y.png" alt="Full wiring diagram: DS18B20 adapter, Keyestudio TDS and turbidity modules, voltage divider, and the XIAO ESP32-C3's 5V, GND, 3V3, D4, D1 and D2 pins" width="800" height="620"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 0, the power strip.&lt;/strong&gt; The XIAO has exactly one GND pin and one 3V3 pin, and four boards need each. So: black M-F jumper from XIAO GND to the breadboard's − rail, orange M-F jumper from XIAO 3V3 to the + rail. Put tape on the + rail that says "3V3, not 5V".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensor 1, temperature (D4).&lt;/strong&gt; Screw the probe's three wires into the adapter (red→VCC, yellow→DAT, black→GND). Adapter VCC from the + rail, adapter GND from the − rail, and a yellow F-F jumper from XIAO &lt;strong&gt;D4&lt;/strong&gt; to the adapter's DAT. The metal probe and cable can go fully underwater; the adapter stays dry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensor 2, TDS (D1).&lt;/strong&gt; Plug the 3-wire cable into the board. Black (−) to the − rail, red (+) to the &lt;strong&gt;3V3&lt;/strong&gt; rail (yes, 3V3, not 5V), and the yellow (A) signal to XIAO &lt;strong&gt;D1&lt;/strong&gt;. Only the probe wand goes in the water.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sensor 3, turbidity through the divider (D2).&lt;/strong&gt; Slide the board's switch to &lt;strong&gt;A&lt;/strong&gt; (analog). Red F-F jumper from XIAO &lt;strong&gt;5V&lt;/strong&gt; to the cable's V pin (this is the only 5V wire in the whole build, and it skips the power strip). G to the − rail. The S (signal) wire goes into the divider's &lt;strong&gt;VCC screw terminal&lt;/strong&gt;, not the XIAO.&lt;/p&gt;

&lt;p&gt;Then the divider: black M-M jumper from the − rail into the divider's GND screw terminal, black M-F jumper from the − rail to the divider's − pin, and a blue F-F jumper from the divider's &lt;strong&gt;S pin&lt;/strong&gt; to XIAO &lt;strong&gt;D2&lt;/strong&gt;. Leave the divider's + pin empty.&lt;/p&gt;

&lt;p&gt;The divider's labels confuse everyone the first time: the terminal marked VCC is the input (IN+), the terminal marked GND is IN−, the pin marked S is the output, and − is ground.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3ksqmuk4iwvfb5oa6l9.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3ksqmuk4iwvfb5oa6l9.png" alt="How the divider works: two resistors in series split the voltage, the output is taken across the smaller one, so 4.5 V in becomes 0.9 V out" width="799" height="293"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This module divides by 5, so 4.5 V comes out as 0.9 V. Safe, with less resolution, because the ESP32's ADC is accurate only to about 2.5 V and you're using a third of it. Two 10 kΩ resistors as a ÷2 divider (max 2.25 V out) gives more detail. Either way, you multiply the reading back by the ratio in code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The whole map:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;XIAO pin&lt;/th&gt;
&lt;th&gt;GPIO&lt;/th&gt;
&lt;th&gt;Connects to&lt;/th&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3V3&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;+ rail, then VCC / +&lt;/td&gt;
&lt;td&gt;Temp adapter, TDS board&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GND&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;− rail, then all grounds&lt;/td&gt;
&lt;td&gt;Temp, TDS, turbidity, divider (×2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5V&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;V&lt;/td&gt;
&lt;td&gt;Turbidity board only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D4&lt;/td&gt;
&lt;td&gt;GPIO6&lt;/td&gt;
&lt;td&gt;DAT&lt;/td&gt;
&lt;td&gt;Temp adapter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D1&lt;/td&gt;
&lt;td&gt;GPIO3 (ADC1)&lt;/td&gt;
&lt;td&gt;A&lt;/td&gt;
&lt;td&gt;TDS board&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;D2&lt;/td&gt;
&lt;td&gt;GPIO4 (ADC1)&lt;/td&gt;
&lt;td&gt;S (output)&lt;/td&gt;
&lt;td&gt;Voltage divider&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Before you plug in USB:&lt;/strong&gt; the − rail has six wires, the + rail has three (nothing from 5V), only one wire on 5V, turbidity S goes to the divider and D2 comes from the divider's S pin, every board is dry and above the water line, and each cable hangs in a drip loop below the electronics.&lt;/p&gt;

&lt;h2&gt;
  
  
  The firmware, in outline (PlatformIO)
&lt;/h2&gt;

&lt;p&gt;The XIAO's job is small: read the three probes every 30 seconds, clean the readings, and send one averaged reading every five minutes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[env:xiao_c3]&lt;/span&gt;
&lt;span class="py"&gt;platform&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;espressif32&lt;/span&gt;
&lt;span class="py"&gt;board&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;seeed_xiao_esp32c3&lt;/span&gt;
&lt;span class="py"&gt;framework&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;arduino&lt;/span&gt;
&lt;span class="py"&gt;monitor_speed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;115200&lt;/span&gt;
&lt;span class="py"&gt;build_flags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
  &lt;span class="py"&gt;-DARDUINO_USB_MODE&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
  &lt;span class="py"&gt;-DARDUINO_USB_CDC_ON_BOOT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="py"&gt;lib_deps&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
  &lt;span class="err"&gt;paulstoffregen/OneWire&lt;/span&gt;
  &lt;span class="err"&gt;milesburton/DallasTemperature&lt;/span&gt;
  &lt;span class="err"&gt;bblanchon/ArduinoJson@^7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two &lt;code&gt;build_flags&lt;/code&gt; make &lt;code&gt;Serial&lt;/code&gt; work over the XIAO's own USB-C port, so you can watch readings with nothing but the cable. The pin map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="cp"&gt;#define TEMP_PIN D4        // GPIO6, DS18B20 DAT (adapter has the pull-up)
#define TDS_PIN  D1        // GPIO3, ADC1
#define TURB_PIN D2        // GPIO4, ADC1, through the /5 divider module
#define TURB_RATIO 5.0f    // 2.0f if you used the two-resistor divider
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both analog pins are on ADC1 on purpose: on an ESP32, ADC2 stops working while Wi-Fi is on. Read them with &lt;code&gt;analogReadMilliVolts()&lt;/code&gt;, which applies the chip's factory calibration.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="nf"&gt;medianMv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;     &lt;span class="c1"&gt;// 32 reads, keep the median&lt;/span&gt;
  &lt;span class="kt"&gt;uint16_t&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;analogReadMilliVolts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pin&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;static&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;sampleOnce&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;temp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;requestTemperatures&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;        &lt;span class="c1"&gt;// ~750 ms at 12-bit&lt;/span&gt;
  &lt;span class="kt"&gt;float&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;temp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;getTempCByIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mf"&gt;85.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;accT&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;nT&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// drop -127 and 85&lt;/span&gt;
  &lt;span class="n"&gt;accTds&lt;/span&gt;  &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;medianMv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TDS_PIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1000.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;accTurb&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;medianMv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TURB_PIN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;1000.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;TURB_RATIO&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;nA&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things in there are worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Median, not average, on the analog pins.&lt;/strong&gt; The filter pump and the lamp timer put spikes on the water and on the supply. 32 reads 10 ms apart with the median taken throws those out; an average lets one spike move the number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reject the DS18B20's lies.&lt;/strong&gt; The Dallas library returns −127 °C with no probe and exactly 85 °C at power-on before the first conversion. Both are "no reading", not readings. A loose DAT wire shows up as a steady 85 °C.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One probe at a time.&lt;/strong&gt; The TDS and turbidity probes both put a small voltage into the same water, and reading them together makes the TDS number jumpy. Sequential reads fix most of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The main loop keeps sampling and sending on separate timers, with a hardware watchdog and a reboot after eight failed sends in a row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight cpp"&gt;&lt;code&gt;&lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;esp_task_wdt_reset&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kt"&gt;uint32_t&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;millis&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lastSample&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;SAMPLE_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;lastSample&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;sampleOnce&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lastSend&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;sendEveryMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;lastSend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;pushReading&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WiFi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;WL_CONNECTED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;connectWifi&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;WiFi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;WL_CONNECTED&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;sendBuffered&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ESP&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't read the probes inside the send function "to get fresh numbers": a failed TLS handshake can take 15 seconds, and now your reading cadence depends on the network.&lt;/p&gt;

&lt;p&gt;The firmware sends &lt;strong&gt;raw volts&lt;/strong&gt; for TDS and turbidity and °C for temperature. The maths lives on the server (or in HA), with the calibration constants as settings, so a recalibration is a settings change and not a reflash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Volts to numbers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TDS&lt;/strong&gt;, temperature-compensated, is the standard formula the Keyestudio and DFRobot boards document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;coeff = 1 + 0.02 * (T - 25)
Vc    = V / coeff
ppm   = (133.42*Vc^3 - 255.86*Vc^2 + 857.39*Vc) * 0.5 * K
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;K&lt;/code&gt; starts at 1.0 and gets set once against a handheld TDS pen or a 342 ppm calibration solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turbidity&lt;/strong&gt; I report as a clarity percentage, not NTU, because the cheap probe isn't accurate enough for real NTU and I'd rather show an honest relative number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sensorV   = adcV * 5                      (the divider ratio)
clarity % = 100 * (V - V_dirty) / (V_clear - V_dirty), clamped to 0-100
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;V_clear&lt;/code&gt; is the probe in clean tap water in a dark cup; &lt;code&gt;V_dirty&lt;/code&gt; is a cup of water with a pinch of soil in it. Both are settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Straight into Home Assistant with ESPHome
&lt;/h2&gt;

&lt;p&gt;If you run Home Assistant you can skip the custom firmware entirely. ESPHome turns a YAML file into firmware and flashes it from the browser; the wiring above does not change. This YAML is for exactly that pin map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;esphome&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;turtle-water&lt;/span&gt;

&lt;span class="na"&gt;esp32&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;board&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;seeed_xiao_esp32c3&lt;/span&gt;
  &lt;span class="na"&gt;framework&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;esp-idf&lt;/span&gt;

&lt;span class="na"&gt;wifi&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ssid&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!secret&lt;/span&gt; &lt;span class="s"&gt;wifi_ssid&lt;/span&gt;
  &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!secret&lt;/span&gt; &lt;span class="s"&gt;wifi_password&lt;/span&gt;

&lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;encryption&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!secret&lt;/span&gt; &lt;span class="s"&gt;api_key&lt;/span&gt;
&lt;span class="na"&gt;ota&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;esphome&lt;/span&gt;
    &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!secret&lt;/span&gt; &lt;span class="s"&gt;ota_password&lt;/span&gt;

&lt;span class="na"&gt;one_wire&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpio&lt;/span&gt;
    &lt;span class="na"&gt;pin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GPIO6&lt;/span&gt;            &lt;span class="c1"&gt;# XIAO D4&lt;/span&gt;

&lt;span class="na"&gt;sensor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dallas_temp&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tank&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;water&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;temperature"&lt;/span&gt;
    &lt;span class="na"&gt;update_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;filter_out&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;85.0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;filter_out&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;-127.0&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adc&lt;/span&gt;
    &lt;span class="na"&gt;pin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GPIO3&lt;/span&gt;            &lt;span class="c1"&gt;# XIAO D1, TDS board A&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tank&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;TDS&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;volts"&lt;/span&gt;
    &lt;span class="na"&gt;attenuation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12db&lt;/span&gt;
    &lt;span class="na"&gt;update_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;median&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;window_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
          &lt;span class="na"&gt;send_every&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;

  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adc&lt;/span&gt;
    &lt;span class="na"&gt;pin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GPIO4&lt;/span&gt;            &lt;span class="c1"&gt;# XIAO D2, divider output&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tank&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;turbidity&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;volts"&lt;/span&gt;
    &lt;span class="na"&gt;attenuation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;12db&lt;/span&gt;
    &lt;span class="na"&gt;update_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;multiply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5.0&lt;/span&gt;     &lt;span class="c1"&gt;# the divider ratio&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;median&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;window_size&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;
          &lt;span class="na"&gt;send_every&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives you three entities with 30-second updates, the same median filtering, the same rejection of the DS18B20's 85 and −127 values, and OTA updates for free. The TDS and clarity formulas become two template sensors, with the calibration constants as &lt;code&gt;input_number&lt;/code&gt; helpers so you can calibrate from your phone.&lt;/p&gt;

&lt;p&gt;Automations worth having:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Temperature out of band:&lt;/strong&gt; numeric state trigger below 73 °F or above 85 °F with &lt;code&gt;for: 10 minutes&lt;/code&gt;, so one odd reading doesn't page you. If the heater is on a smart plug, the same automation can switch it off above 85 °F.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Water change reminder:&lt;/strong&gt; an input button that stores the current TDS as a baseline, a template sensor for "TDS above baseline", and an alert at +300 ppm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heater on a smart plug with energy monitoring&lt;/strong&gt;, even if you never automate it. A heater drawing zero watts in a cold tank has failed, and that shows up before the water cools.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When a reading is wrong
&lt;/h2&gt;

&lt;p&gt;The two that catch almost everyone: the temperature probe reading &lt;strong&gt;85 °C&lt;/strong&gt; (never got a successful read, usually a loose DAT wire), and the turbidity board's switch left on &lt;strong&gt;D&lt;/strong&gt; (digital), which gives a flat reading no matter what the water does. Sanity checks before you write any network code: TDS reads near zero in air and a steady number in the tank, turbidity reads higher in clear water and drops when it gets cloudy, and the temperature is a sensible number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The care numbers I built around
&lt;/h2&gt;

&lt;p&gt;Water 76–82 °F (24.5–28 °C) is fine, warn outside that, alert under 73 or over 85 °F. Basking surface 90–95 °F (keep the electronics away from it). UVB 10–12 hours a day on a timer, bulb replaced on schedule. 25–50 % water change weekly for a small tank. And the test kit still runs: ammonia 0, nitrite 0, nitrate under 40 ppm.&lt;/p&gt;




&lt;p&gt;This is the condensed version of the sensor half of my book &lt;strong&gt;Smart Turtle Tank&lt;/strong&gt;, which also covers the turtle cam (ESP32-CAM, with an AI check on each snapshot), the 3D-printed enclosure that holds all of it, the full firmware with the offline buffer and TLS, and a weekend build checklist.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free sample (first chapters): &lt;a href="https://payhip.com/b/2FNPO" rel="noopener noreferrer"&gt;https://payhip.com/b/2FNPO&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The full book, PDF + EPUB: &lt;a href="https://payhip.com/b/k0nzS" rel="noopener noreferrer"&gt;https://payhip.com/b/k0nzS&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Questions about the wiring or the ESPHome config, ask below and I'll answer.&lt;/p&gt;

</description>
      <category>esp32</category>
      <category>homeassistant</category>
      <category>iot</category>
      <category>diy</category>
    </item>
    <item>
      <title>Your Proxmox backup job probably skips the guests you built after you made it. Here's the 3-2-1 setup that doesn't</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Sat, 10 Oct 2026 00:47:20 +0000</pubDate>
      <link>https://dev.to/charleshartmann/your-proxmox-backup-job-probably-skips-the-guests-you-built-after-you-made-it-heres-the-3-2-1-noc</link>
      <guid>https://dev.to/charleshartmann/your-proxmox-backup-job-probably-skips-the-guests-you-built-after-you-made-it-heres-the-3-2-1-noc</guid>
      <description>&lt;p&gt;I ran a Proxmox homelab for years with a backup job that reported success every night. It had been created for "selected VMs" and every guest I built after that was simply not in the list. The job was fine. It was protecting the wrong things.&lt;/p&gt;

&lt;p&gt;This is the setup I run now for a home Proxmox cluster with OPNsense and Home Assistant on it. Nothing exotic: vzdump, Proxmox Backup Server, rclone, and a small amount of discipline around proving it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The job covers everything by default
&lt;/h2&gt;

&lt;p&gt;Datacenter → Backup → Add. The settings that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Selection mode: All&lt;/strong&gt;, with your scratch guests under &lt;em&gt;Exclude&lt;/em&gt;. New guests are protected the day they are created.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mode: Snapshot&lt;/strong&gt;, &lt;strong&gt;Compression: ZSTD&lt;/strong&gt;, nightly at a quiet hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retention: use storage defaults&lt;/strong&gt;, so the numbers live in one place.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notification mode: Notification system&lt;/strong&gt; (not legacy email), so matchers can route failures to your phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Notes template&lt;/strong&gt;: &lt;code&gt;{{guestname}} on {{node}}&lt;/code&gt;, so the backup list is readable at 2 AM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same thing on the command line, which is what the GUI writes into &lt;code&gt;/etc/pve/jobs.cfg&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vzdump &lt;span class="nt"&gt;--all&lt;/span&gt; 1 &lt;span class="nt"&gt;--exclude&lt;/span&gt; 900,901 &lt;span class="nt"&gt;--mode&lt;/span&gt; snapshot &lt;span class="nt"&gt;--compress&lt;/span&gt; zstd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--storage&lt;/span&gt; nas-backup &lt;span class="nt"&gt;--notes-template&lt;/span&gt; &lt;span class="s1"&gt;'{{guestname}} on {{node}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--notification-mode&lt;/span&gt; notification-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put the archives on something that is not the host's own disk. An NFS export on the NAS is the simplest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pvesm add nfs nas-backup &lt;span class="nt"&gt;--server&lt;/span&gt; 192.168.10.40 &lt;span class="nt"&gt;--export&lt;/span&gt; /volume1/proxmox &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--content&lt;/span&gt; backup &lt;span class="nt"&gt;--options&lt;/span&gt; &lt;span class="nv"&gt;vers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4.1
pvesm &lt;span class="nb"&gt;set &lt;/span&gt;nas-backup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--prune-backups&lt;/span&gt; keep-last&lt;span class="o"&gt;=&lt;/span&gt;2,keep-daily&lt;span class="o"&gt;=&lt;/span&gt;7,keep-weekly&lt;span class="o"&gt;=&lt;/span&gt;4,keep-monthly&lt;span class="o"&gt;=&lt;/span&gt;3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install the QEMU guest agent in every VM (&lt;code&gt;apt install qemu-guest-agent&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Add &lt;code&gt;--bwlimit 50000&lt;/code&gt; if the nightly run makes anything laggy. It did for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Retention: keep-daily=7 does not mean seven days
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;keep-daily=7&lt;/code&gt; means "keep the last backup from each of the last seven &lt;em&gt;days that have a backup&lt;/em&gt;". If the job failed for a month and then ran once, that single run satisfies keep-daily, keep-weekly and keep-monthly at the same time, and pruning deletes the older ones. The rules never promise coverage of time.&lt;/p&gt;

&lt;p&gt;So the weekly and monthly buckets matter more than the daily ones, and &lt;code&gt;keep-last&lt;/code&gt; is the safety margin for "I just took a backup before an upgrade". What I use:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Guest type&lt;/th&gt;
&lt;th&gt;keep-last&lt;/th&gt;
&lt;th&gt;daily&lt;/th&gt;
&lt;th&gt;weekly&lt;/th&gt;
&lt;th&gt;monthly&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Databases, Home Assistant&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;14&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ordinary services&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OPNsense (barely changes)&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Ten minutes with the Prune Simulator in the PBS docs fixed a misunderstanding I had carried for years.&lt;/p&gt;

&lt;p&gt;Before a risky change, take a manual backup and mark it &lt;strong&gt;Protected&lt;/strong&gt; (the shield icon). It survives every prune until you clear the flag.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Proxmox Backup Server, with the key saved somewhere else
&lt;/h2&gt;

&lt;p&gt;vzdump copies the whole guest every night. PBS splits every disk into chunks, stores each unique chunk once, and after the first run only ships what changed. Thirty nightly backups of a 32 GB Home Assistant VM take a little more space than one.&lt;/p&gt;

&lt;p&gt;Put PBS on its own box, or at least its own disks. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# on PBS&lt;/span&gt;
proxmox-backup-manager datastore create backups /mnt/datastore/backups
proxmox-backup-manager user create backup@pbs
proxmox-backup-manager acl update /datastore/backups DatastoreBackup &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--auth-id&lt;/span&gt; backup@pbs
proxmox-backup-manager cert info | &lt;span class="nb"&gt;grep &lt;/span&gt;Fingerprint

&lt;span class="c"&gt;# on the Proxmox node&lt;/span&gt;
pvesm add pbs pbs01 &lt;span class="nt"&gt;--server&lt;/span&gt; 192.168.10.30 &lt;span class="nt"&gt;--datastore&lt;/span&gt; backups &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--username&lt;/span&gt; backup@pbs &lt;span class="nt"&gt;--password&lt;/span&gt; &lt;span class="s1"&gt;'...'&lt;/span&gt; &lt;span class="nt"&gt;--fingerprint&lt;/span&gt; &lt;span class="s1"&gt;'aa:bb:...'&lt;/span&gt;
pvesm &lt;span class="nb"&gt;set &lt;/span&gt;pbs01 &lt;span class="nt"&gt;--encryption-key&lt;/span&gt; autogen
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line encrypts every chunk on the client before it leaves the node, and writes the key to &lt;code&gt;/etc/pve/priv/storage/pbs01.enc&lt;/code&gt;. &lt;strong&gt;Copy that key somewhere that is not this cluster&lt;/strong&gt; (password manager, printed QR). Without it, every encrypted backup is unrecoverable. With it, you can restore onto a brand-new Proxmox install. It is the one mistake in all of this with no undo.&lt;/p&gt;

&lt;p&gt;On the PBS side, schedule three jobs under the datastore: a prune job with the same keep-* numbers, daily garbage collection (pruning only unlinks; GC frees chunks, after a 24-hour grace period), and a weekly verify job with "skip verified" on and "re-verify after" set to 30 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Off-site: rclone with a crypt layer
&lt;/h2&gt;

&lt;p&gt;Everything above is still in one house. The cheapest third copy I know of is an encrypted rclone sync to object storage. Two remotes: the raw bucket, and a crypt remote on top so the provider only ever sees encrypted names and content.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rclone config
&lt;span class="c"&gt;# n) name: b2raw, type: b2, paste the key ID and key (scoped to one bucket)&lt;/span&gt;
&lt;span class="c"&gt;# n) name: b2, type: crypt, remote: b2raw:homelab-backups, two passwords&lt;/span&gt;
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.config/rclone/rclone.conf

rclone &lt;span class="nb"&gt;sync&lt;/span&gt; /mnt/backups/dump b2:dump &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--transfers&lt;/span&gt; 4 &lt;span class="nt"&gt;--bwlimit&lt;/span&gt; &lt;span class="s2"&gt;"08:00,2M 23:00,off"&lt;/span&gt; &lt;span class="nt"&gt;--fast-list&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-file&lt;/span&gt; /var/log/rclone-b2.log &lt;span class="nt"&gt;--log-level&lt;/span&gt; INFO
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sync&lt;/code&gt; mirrors deletions, which is right for a dump directory that prunes itself. Set a lifecycle rule on the bucket so hidden old versions expire after ~30 days, and use &lt;code&gt;rclone copy&lt;/code&gt; (never deletes) for anything irreplaceable. &lt;code&gt;rclone check b2:dump /mnt/backups/dump&lt;/code&gt; compares without transferring; run it monthly.&lt;/p&gt;

&lt;p&gt;The crypt passwords go in the same place as the PBS key.&lt;/p&gt;

&lt;p&gt;If a friend runs PBS too, a push sync job over Tailscale is even better: &lt;code&gt;proxmox-backup-manager sync-job create ... --sync-direction push&lt;/code&gt;. Client-side encryption means neither of you can read the other's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The alert you actually need is "nothing succeeded recently"
&lt;/h2&gt;

&lt;p&gt;A job that never runs never fails. The off-site sync that stopped in February will not send a single error until the day you need it. Alerting on failure is not enough; you need a dead-man switch that expects a ping.&lt;/p&gt;

&lt;p&gt;vzdump has hook scripts for exactly this. In &lt;code&gt;/etc/vzdump.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;script: /usr/local/bin/vzdump-hook.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# vzdump calls this with the phase as $1&lt;/span&gt;
&lt;span class="nv"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://hc-ping.com/your-uuid-here   &lt;span class="c"&gt;# healthchecks.io, or an Uptime Kuma push monitor&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
  &lt;/span&gt;job-end&lt;span class="p"&gt;)&lt;/span&gt;   curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 &lt;span class="nt"&gt;--retry&lt;/span&gt; 3 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="p"&gt;;;&lt;/span&gt;
  job-abort&lt;span class="p"&gt;)&lt;/span&gt; curl &lt;span class="nt"&gt;-fsS&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; 10 &lt;span class="nt"&gt;--retry&lt;/span&gt; 3 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$URL&lt;/span&gt;&lt;span class="s2"&gt;/fail"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. Do the same with a &lt;code&gt;&amp;amp;&amp;amp; curl&lt;/code&gt; at the end of the rclone cron line.&lt;/p&gt;

&lt;p&gt;Hooks prove the job ran; they don't prove it covered every guest. This weekly script flags any guest whose newest backup is older than two days, and any guest with no backup at all (the gap a hand-picked job list leaves):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;STORE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pbs01&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;MAXAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;API&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/nodes/&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;/storage/&lt;span class="nv"&gt;$STORE&lt;/span&gt;/content
pvesh get &lt;span class="nv"&gt;$API&lt;/span&gt; &lt;span class="nt"&gt;--content&lt;/span&gt; backup &lt;span class="nt"&gt;--output-format&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
 | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | "\(.vmid) \(.ctime)"'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-k1&lt;/span&gt;,1n &lt;span class="nt"&gt;-k2&lt;/span&gt;,2nr | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-k1&lt;/span&gt;,1n &lt;span class="se"&gt;\&lt;/span&gt;
 | &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; vmid ctime&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
     &lt;/span&gt;&lt;span class="nv"&gt;age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;now &lt;span class="o"&gt;-&lt;/span&gt; ctime&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="m"&gt;86400&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
     &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$age&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MAXAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"STALE: guest &lt;/span&gt;&lt;span class="nv"&gt;$vmid&lt;/span&gt;&lt;span class="s2"&gt;, last backup &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;age&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;d"&lt;/span&gt;
   &lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nv"&gt;ids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;qm list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pct list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR&amp;gt;1{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="nb"&gt;id &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$ids&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;pvesh get &lt;span class="nv"&gt;$API&lt;/span&gt; &lt;span class="nt"&gt;--content&lt;/span&gt; backup &lt;span class="nt"&gt;--vmid&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--output-format&lt;/span&gt; json &lt;span class="se"&gt;\&lt;/span&gt;
    | jq &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'length &amp;gt; 0'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"NONE: guest &lt;/span&gt;&lt;span class="nv"&gt;$id&lt;/span&gt;&lt;span class="s2"&gt; has no backup"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mail or push the output only when it is not empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Restore something every quarter
&lt;/h2&gt;

&lt;p&gt;A PBS verify job proves chunks match their hashes. &lt;code&gt;zstd -t&lt;/code&gt; proves an archive is complete. Neither proves the guest boots. Four times a year, one evening: restore a VM from PBS to a new ID with the network unplugged, restore a container from the vzdump copy, pull one file with File Restore, and pull one archive back from off-site. Write down how long each took. Then destroy the test guests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qmrestore pbs01:backup/vm/101/2026-10-08T01:30:00Z 9101 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--storage&lt;/span&gt; local-zfs &lt;span class="nt"&gt;--unique&lt;/span&gt; 1
qm &lt;span class="nb"&gt;set &lt;/span&gt;9101 &lt;span class="nt"&gt;--net0&lt;/span&gt; virtio,bridge&lt;span class="o"&gt;=&lt;/span&gt;vmbr0,link_down&lt;span class="o"&gt;=&lt;/span&gt;1
qm start 9101
&lt;span class="c"&gt;# look at it, then&lt;/span&gt;
qm destroy 9101 &lt;span class="nt"&gt;--purge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first time you watch a restored VM boot, the whole system stops being a hope and becomes a fact.&lt;/p&gt;




&lt;p&gt;I wrote all of this up properly, with Home Assistant and OPNsense config backups, the backup → verify → destroy cleanup routine, and a weekend checklist. One chapter plus the checklist is free as a PDF: &lt;a href="https://payhip.com/b/qbkVG" rel="noopener noreferrer"&gt;https://payhip.com/b/qbkVG&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The full book, &lt;em&gt;Never Lose Your Data: A Homelabber's Backup Playbook&lt;/em&gt; (41 pages, PDF + EPUB), is $19: &lt;a href="https://payhip.com/b/1iHXQ" rel="noopener noreferrer"&gt;https://payhip.com/b/1iHXQ&lt;/a&gt;&lt;/p&gt;

</description>
      <category>proxmox</category>
      <category>homelab</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>Backup, verify, destroy: how I deleted 50 Proxmox guests in one night and could undo any of them</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Sat, 10 Oct 2026 00:14:44 +0000</pubDate>
      <link>https://dev.to/charleshartmann/backup-verify-destroy-how-i-deleted-50-proxmox-guests-in-one-night-and-could-undo-any-of-them-30</link>
      <guid>https://dev.to/charleshartmann/backup-verify-destroy-how-i-deleted-50-proxmox-guests-in-one-night-and-could-undo-any-of-them-30</guid>
      <description>&lt;p&gt;If your Proxmox guest list is mostly grey "stopped" icons, this is the routine I used this week to delete 50 of them and get about 700 GB back, with every single one recoverable.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, find out what's actually unused
&lt;/h2&gt;

&lt;p&gt;Stopped is not the same as unused. A template, or a plain container someone turned into a template, is always stopped and may be the base of linked clones. Deleting it takes every clone with it.&lt;/p&gt;

&lt;p&gt;For every candidate ID, grep the cluster's configs for a disk that references it as a base:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"base-&amp;lt;id&amp;gt;-"&lt;/span&gt; /etc/pve/nodes/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.conf
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If anything comes back, that guest is a base image. I found two bases that six running containers depended on. Tag those &lt;code&gt;keep&lt;/code&gt; and move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then, for each real candidate: backup, verify, destroy
&lt;/h2&gt;

&lt;p&gt;Back it up first. &lt;code&gt;--mode stop&lt;/code&gt; gives a consistent image of a guest that is already stopped anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vzdump &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;--storage&lt;/span&gt; &amp;lt;backupstore&amp;gt; &lt;span class="nt"&gt;--compress&lt;/span&gt; zstd &lt;span class="nt"&gt;--mode&lt;/span&gt; stop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify the archive before you trust it. A backup you haven't at least integrity-checked is a rumor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zstd &lt;span class="nt"&gt;-t&lt;/span&gt; /path/to/vzdump-lxc-&amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;-&lt;span class="k"&gt;*&lt;/span&gt;.tar.zst
&lt;span class="c"&gt;# VMs:&lt;/span&gt;
zstd &lt;span class="nt"&gt;-t&lt;/span&gt; /path/to/vzdump-qemu-&amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;-&lt;span class="k"&gt;*&lt;/span&gt;.vma.zst
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only then destroy it. &lt;code&gt;--purge&lt;/code&gt; matters: without it the old backup job keeps referencing the dead ID and fails on its next run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pct destroy &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;--purge&lt;/span&gt;
&lt;span class="c"&gt;# VMs:&lt;/span&gt;
qm destroy &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;--purge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the backup storage's free space as you go. I went in order of "would be saddest to lose" and stopped when the share got tight.&lt;/p&gt;

&lt;h2&gt;
  
  
  Afterwards: the leftovers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Orphan disks: &lt;code&gt;pvesm list &amp;lt;storage&amp;gt;&lt;/code&gt; entries that no config references.&lt;/li&gt;
&lt;li&gt;Old ISOs and CT templates sitting in &lt;code&gt;local&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Snapshots older than a year.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That was another ~100 GB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gotchas
&lt;/h2&gt;

&lt;p&gt;Running the vzdumps at full speed made the host's busy containers laggy, so I added &lt;code&gt;--bwlimit&lt;/code&gt; to the vzdump command.&lt;/p&gt;

&lt;p&gt;On a two-node cluster, the second node kept losing quorum (it's on Wi-Fi), so every destroy on it had to be retried while quorate. A QDevice fixed that properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;List every stopped guest.&lt;/li&gt;
&lt;li&gt;Grep the configs for &lt;code&gt;base-&amp;lt;id&amp;gt;-&lt;/code&gt;; tag any hit &lt;code&gt;keep&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For the rest: vzdump with zstd, &lt;code&gt;zstd -t&lt;/code&gt; the file, then destroy with &lt;code&gt;--purge&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check the backup job still covers everything that's left (mine was set to "selected VMs" years ago and covered half the guests).&lt;/li&gt;
&lt;li&gt;Clean up orphan disks, old ISOs, old snapshots.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;This is Chapter 4 of &lt;em&gt;Homelab the Right Way&lt;/em&gt; (Proxmox, OPNsense &amp;amp; Home Assistant Without the Pain). The chapter and the weekend checklist are free: &lt;a href="https://payhip.com/b/qon0W" rel="noopener noreferrer"&gt;https://payhip.com/b/qon0W&lt;/a&gt;. The full 40-page book is $19: &lt;a href="https://payhip.com/HomelabtheRightWay" rel="noopener noreferrer"&gt;https://payhip.com/HomelabtheRightWay&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>homelab</category>
      <category>proxmox</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
