<?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>Test your Compose ViewModels in milliseconds: fakes, runTest, the Main dispatcher and Turbine</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:00:57 +0000</pubDate>
      <link>https://dev.to/charleshartmann/test-your-compose-viewmodels-in-milliseconds-fakes-runtest-the-main-dispatcher-and-turbine-2gg4</link>
      <guid>https://dev.to/charleshartmann/test-your-compose-viewmodels-in-milliseconds-fakes-runtest-the-main-dispatcher-and-turbine-2gg4</guid>
      <description>&lt;p&gt;The ViewModel is where a Compose screen's behavior lives: what the screen shows while loading, what happens when the user saves, which error appears when the network fails. It is plain Kotlin and runs on the JVM, so its tests take milliseconds and need no emulator. That makes it the best-value test in an Android app. This post tests a small note editor's ViewModel with a fake repository, coroutine test dispatchers, Turbine and &lt;code&gt;SavedStateHandle&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The code under test
&lt;/h2&gt;

&lt;p&gt;The editor loads a note by id, lets the user change the title and body, and saves. Its state is a single &lt;code&gt;StateFlow&amp;lt;EditorUiState&amp;gt;&lt;/code&gt;, and the important parts look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EditorViewModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NotesRepository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;savedStateHandle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SavedStateHandle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;ViewModel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;savedStateHandle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"noteId"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;_state&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MutableStateFlow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EditorUiState&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;StateFlow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;EditorUiState&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asStateFlow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nf"&gt;init&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;viewModelScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;note&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;noteId&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;savedStateHandle&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"draftTitle"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orEmpty&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;orEmpty&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                    &lt;span class="n"&gt;isLoading&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;current&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&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;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isBlank&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Title is required"&lt;/span&gt;&lt;span class="p"&gt;)&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;viewModelScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isSaving&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;SaveResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Ok&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isSaving&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nc"&gt;SaveResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;_state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;isSaving&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// onTitleChange() also writes savedStateHandle["draftTitle"]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NotesRepository&lt;/code&gt; is an interface. That one decision makes the ViewModel testable: a test can hand it anything that implements the interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fakes, not mocks, by default
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;mock&lt;/strong&gt; (MockK, for example) is configured per test: you tell it what to return and afterwards verify which calls were made. A &lt;strong&gt;fake&lt;/strong&gt; is a small, real implementation written by hand, usually backed by a map in memory. Google's testing guidance recommends fakes as the default test double, for practical reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A fake behaves like the real thing, so tests check behavior ("after saving, the note is in the repository") rather than implementation details ("&lt;code&gt;save&lt;/code&gt; was called once with these arguments").&lt;/li&gt;
&lt;li&gt;A fake is written once and reused by every test. Mocks are configured again in every test.&lt;/li&gt;
&lt;li&gt;Refactoring the ViewModel does not break tests that use a fake, as long as the behavior stays the same.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Keep mocks for the edges: a third-party class you cannot implement easily, or a check that something was &lt;em&gt;not&lt;/em&gt; called.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;FakeNotesRepository&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;NotesRepository&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mutableMapOf&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;nextId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1L&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;failNextSave&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;saveDelayMs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;vararg&lt;/span&gt; &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;nextId&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maxOrNull&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="mi"&gt;0L&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;getNote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;?,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nc"&gt;SaveResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;saveDelayMs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;// virtual time in tests&lt;/span&gt;
        &lt;span class="n"&gt;failNextSave&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;failNextSave&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SaveResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Failed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;note&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="n"&gt;nextId&lt;/span&gt;&lt;span class="p"&gt;++,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SaveResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Note&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toList&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;h2&gt;
  
  
  Step 1: replace the Main dispatcher
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;viewModelScope&lt;/code&gt; launches on &lt;code&gt;Dispatchers.Main&lt;/code&gt;. On the JVM there is no Android main looper, so a test that creates this ViewModel fails with an error about the Main dispatcher missing. The fix is &lt;code&gt;Dispatchers.setMain&lt;/code&gt; and &lt;code&gt;Dispatchers.resetMain&lt;/code&gt; from kotlinx-coroutines-test, and the Android Developers coroutine testing guide recommends wrapping them in a JUnit rule so no test forgets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MainDispatcherRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;testDispatcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TestDispatcher&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;UnconfinedTestDispatcher&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;TestWatcher&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;starting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setMain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;testDispatcher&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;finished&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resetMain&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every ViewModel test then starts with one line: &lt;code&gt;@get:Rule val mainDispatcherRule = MainDispatcherRule()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: understand runTest and the two dispatchers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;runTest&lt;/code&gt; runs the test body as a coroutine with &lt;strong&gt;virtual time&lt;/strong&gt;: a &lt;code&gt;delay(5_000)&lt;/code&gt; inside it completes instantly, while the test still sees five seconds pass. There are two test dispatchers, and the difference matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;StandardTestDispatcher&lt;/code&gt; queues new coroutines. They do not run until the test yields, with &lt;code&gt;advanceUntilIdle()&lt;/code&gt;, &lt;code&gt;advanceTimeBy()&lt;/code&gt;, &lt;code&gt;runCurrent()&lt;/code&gt; or by suspending. It is the default in &lt;code&gt;runTest&lt;/code&gt;, and it lets you check intermediate states such as "saving is in progress".&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;UnconfinedTestDispatcher&lt;/code&gt; starts new coroutines eagerly until their first suspension. Simple tests read shorter, but you cannot observe the state between "launched" and "finished".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When &lt;code&gt;MainDispatcherRule&lt;/code&gt; installs a test dispatcher as Main, &lt;code&gt;runTest&lt;/code&gt; reuses its scheduler, so &lt;code&gt;advanceUntilIdle()&lt;/code&gt; in the test also drives coroutines launched in &lt;code&gt;viewModelScope&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: the first tests
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EditorViewModelTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;

    &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nc"&gt;Rule&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;mainDispatcherRule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;MainDispatcherRule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;StandardTestDispatcher&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;repository&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FakeNotesRepository&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Long&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EditorViewModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nc"&gt;SavedStateHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"noteId"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;saving_blank_note_shows_error_and_saves_nothing&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;vm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;advanceUntilIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onTitleChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"   "&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;advanceUntilIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Title is required"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;isEmpty&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;saving_shows_progress_then_marks_saved&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;saveDelayMs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2_000&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;vm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;advanceUntilIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onTitleChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Groceries"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;runCurrent&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                       &lt;span class="c1"&gt;// the save starts, then suspends in delay()&lt;/span&gt;
        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isSaving&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isTrue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="nf"&gt;advanceUntilIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;                 &lt;span class="c1"&gt;// two virtual seconds pass instantly&lt;/span&gt;
        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isTrue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The classic mistake is asserting before advancing. With &lt;code&gt;StandardTestDispatcher&lt;/code&gt;, the coroutine launched in &lt;code&gt;init&lt;/code&gt; has not run when the constructor returns, so a test that reads &lt;code&gt;state.value.title&lt;/code&gt; straight away sees the default empty title and fails, or worse, passes for the wrong reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: sequences of states with Turbine
&lt;/h2&gt;

&lt;p&gt;When the order of states is the behavior, Turbine collects the flow and lets you await each item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;save_emits_saving_then_saved&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;runTest&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;saveDelayMs&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1_000&lt;/span&gt;         &lt;span class="c1"&gt;// a suspension between the two updates&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;vm&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;viewModel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="nf"&gt;advanceUntilIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onTitleChange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Packing list"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;awaitItem&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;isSaving&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isFalse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;// current state&lt;/span&gt;
        &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;awaitItem&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;isSaving&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isTrue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;awaitItem&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;saved&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isTrue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;cancelAndIgnoreRemainingEvents&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StateFlow&lt;/code&gt; is &lt;strong&gt;conflated&lt;/strong&gt;: if two updates happen before the collector runs, it sees only the latest, and equal consecutive values are dropped. A test that expects a state that was immediately replaced waits and times out. That is why the fake has a delay: the suspension between "saving" and "saved" lets the collector see both. Assert on the states that matter, and make sure something suspends between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stateIn trap
&lt;/h2&gt;

&lt;p&gt;A flow exposed with &lt;code&gt;stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())&lt;/code&gt; runs its upstream only while someone collects it. Reading &lt;code&gt;.value&lt;/code&gt; in a test with nobody collecting returns the initial empty list forever. Collect it in &lt;code&gt;backgroundScope&lt;/code&gt;, which &lt;code&gt;runTest&lt;/code&gt; cancels for you at the end:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="n"&gt;backgroundScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;UnconfinedTestDispatcher&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;testScheduler&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;vm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&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;h2&gt;
  
  
  Process death, at the ViewModel level
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;SavedStateHandle&lt;/code&gt; has a public constructor that takes a map, so you can simulate the system recreating the ViewModel: write a value through the first instance, build a second one with a handle holding the same values, and assert the state comes back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;restored&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EditorViewModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repository&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;SavedStateHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;mapOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;"noteId"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"draftTitle"&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="s"&gt;"draftTitle"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;span class="nf"&gt;advanceUntilIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;assertThat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;restored&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isEqualTo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Half-typed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The ViewModel depends on interfaces, and each has a reusable fake.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;MainDispatcherRule&lt;/code&gt; replaces &lt;code&gt;Dispatchers.Main&lt;/code&gt; in every ViewModel test.&lt;/li&gt;
&lt;li&gt;Tests advance (&lt;code&gt;advanceUntilIdle()&lt;/code&gt;, &lt;code&gt;runCurrent()&lt;/code&gt;) before asserting.&lt;/li&gt;
&lt;li&gt;Turbine tests have a real suspension between the states they expect.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;stateIn&lt;/code&gt; flows are collected in &lt;code&gt;backgroundScope&lt;/code&gt; before reading &lt;code&gt;.value&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Saved state is tested by rebuilding the ViewModel from a &lt;code&gt;SavedStateHandle&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is adapted from my book &lt;em&gt;Test It Before They Do&lt;/em&gt; (Android the Right Way, Book 8). The free sample is &lt;a href="https://payhip.com/b/I905N" rel="noopener noreferrer"&gt;here&lt;/a&gt; and the full book is &lt;a href="https://payhip.com/b/Fpyu8" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Want the week's homelab articles, videos and one tip from the books in your inbox every Sunday? &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/newsletter/" rel="noopener noreferrer"&gt;Get the free weekly email&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI help and edited by the author; based on his book.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>testing</category>
      <category>jetpackcompose</category>
    </item>
    <item>
      <title>Real HTTPS for your homelab Kubernetes apps without opening a port: k3s Traefik, cert-manager and a DNS-01 wildcard</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Fri, 30 Oct 2026 14:05:06 +0000</pubDate>
      <link>https://dev.to/charleshartmann/real-https-for-your-homelab-kubernetes-apps-without-opening-a-port-k3s-traefik-cert-manager-and-a-2ek4</link>
      <guid>https://dev.to/charleshartmann/real-https-for-your-homelab-kubernetes-apps-without-opening-a-port-k3s-traefik-cert-manager-and-a-2ek4</guid>
      <description>&lt;p&gt;Every web app in a homelab cluster should answer at a proper hostname with a certificate your browser trusts, and you should not have to forward a single port from the internet to get there. On k3s you already have most of the pieces: the Traefik ingress controller ships with it. Add cert-manager, one DNS-01 issuer and one wildcard certificate, and every app you route gets real HTTPS automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;There are two traps, one on each side.&lt;/p&gt;

&lt;p&gt;The first is living with certificate warnings: self-signed certificates on every app, a browser you have trained to click "proceed anyway", and a password manager that refuses to autofill. That habit is exactly what an attacker on your network would rely on.&lt;/p&gt;

&lt;p&gt;The second is fixing it the obvious way: opening port 80 to the internet so Let's Encrypt can run an HTTP-01 challenge. That publishes your lab to every scanner on the internet for the sake of a padlock icon.&lt;/p&gt;

&lt;p&gt;The DNS-01 challenge avoids both. You prove you control the domain by creating a DNS record through your DNS provider's API. Nothing on your network has to be reachable from outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a request reaches a pod
&lt;/h2&gt;

&lt;p&gt;When something does not load, walk this path in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your browser asks LAN DNS for &lt;code&gt;paperless.home.example.com&lt;/code&gt; and gets the private address of Traefik's LoadBalancer Service (for example &lt;code&gt;192.168.40.200&lt;/code&gt;, handed out by MetalLB).&lt;/li&gt;
&lt;li&gt;The node that holds that address receives the connection and hands it to a Traefik pod.&lt;/li&gt;
&lt;li&gt;Traefik terminates TLS, matches the hostname against its routes, and forwards the request to the app's ClusterIP Service.&lt;/li&gt;
&lt;li&gt;The Service sends it to one of the app's ready pods.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: check the Traefik that k3s installed
&lt;/h2&gt;

&lt;p&gt;k3s installs Traefik from a bundled Helm chart into &lt;code&gt;kube-system&lt;/code&gt; and registers an IngressClass called &lt;code&gt;traefik&lt;/code&gt;. Because it is the only ingress controller in the cluster, it picks up standard Ingress objects without being told.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get ingressclass
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get deploy,svc traefik
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get helmchart traefik &lt;span class="nt"&gt;-o&lt;/span&gt; yaml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change Traefik's settings with a &lt;code&gt;HelmChartConfig&lt;/code&gt; named &lt;code&gt;traefik&lt;/code&gt;, never by editing the generated chart manifest. One gotcha when you copy older snippets: the chart version k3s ships moved the Service type from &lt;code&gt;service.type&lt;/code&gt; to &lt;code&gt;service.spec.type&lt;/code&gt;, and the k3s documentation warns that the old key is silently ignored.&lt;/p&gt;

&lt;p&gt;While you are there, redirect plain HTTP to HTTPS everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;helm.cattle.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HelmChartConfig&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;valuesContent&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|-&lt;/span&gt;
    &lt;span class="s"&gt;ports:&lt;/span&gt;
      &lt;span class="s"&gt;web:&lt;/span&gt;
        &lt;span class="s"&gt;http:&lt;/span&gt;
          &lt;span class="s"&gt;redirections:&lt;/span&gt;
            &lt;span class="s"&gt;entryPoint:&lt;/span&gt;
              &lt;span class="s"&gt;to: websecure&lt;/span&gt;
              &lt;span class="s"&gt;scheme: https&lt;/span&gt;
              &lt;span class="s"&gt;permanent: true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; traefik-config.yaml
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get pods &lt;span class="nt"&gt;-l&lt;/span&gt; app.kubernetes.io/name&lt;span class="o"&gt;=&lt;/span&gt;traefik &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;k3s re-runs the chart with the merged values and Traefik restarts.&lt;/p&gt;

&lt;p&gt;A note if you are following an older tutorial that installs ingress-nginx first: in November 2025 Kubernetes SIG Network and the Security Response Committee announced the retirement of ingress-nginx, with best-effort maintenance until March 2026 and no releases or security fixes after that. k3s already gives you Traefik. Translate those guides' Ingress objects to the &lt;code&gt;traefik&lt;/code&gt; class, and review any &lt;code&gt;nginx.ingress.kubernetes.io/*&lt;/code&gt; annotations, because Traefik ignores them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: install cert-manager
&lt;/h2&gt;

&lt;p&gt;cert-manager watches &lt;code&gt;Certificate&lt;/code&gt; objects, gets certificates from an issuer such as Let's Encrypt, stores them in Secrets and renews them before they expire. The project publishes its Helm chart in an OCI registry (v1.21.3 at the time of writing; check for the current release):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm &lt;span class="nb"&gt;install &lt;/span&gt;cert-manager oci://quay.io/jetstack/charts/cert-manager &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.21.3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; cert-manager &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; crds.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true
&lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; cert-manager get pods
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;crds.enabled=true&lt;/code&gt; lets the chart install and upgrade cert-manager's CustomResourceDefinitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: a DNS-01 issuer
&lt;/h2&gt;

&lt;p&gt;You need a domain you own, hosted at a DNS provider cert-manager can talk to. It supports several directly (Cloudflare, Route 53, Google Cloud DNS, Azure DNS, DigitalOcean and others) and many more through webhooks. With Cloudflare, follow cert-manager's documentation: create an API &lt;strong&gt;token&lt;/strong&gt;, not the global API key, with &lt;strong&gt;Zone - DNS - Edit&lt;/strong&gt; and &lt;strong&gt;Zone - Zone - Read&lt;/strong&gt;, limited to your zone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; cert-manager create secret generic cloudflare-api-token &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;api-token&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'PASTE-THE-TOKEN'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then a ClusterIssuer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIssuer&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt-prod&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;acme&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://acme-v02.api.letsencrypt.org/directory&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;you@example.com&lt;/span&gt;
    &lt;span class="na"&gt;privateKeySecretRef&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;letsencrypt-prod-account&lt;/span&gt;
    &lt;span class="na"&gt;solvers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;dns01&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;cloudflare&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;apiTokenSecretRef&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;cloudflare-api-token&lt;/span&gt;
              &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api-token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create a second issuer named &lt;code&gt;letsencrypt-staging&lt;/code&gt; with the server &lt;code&gt;https://acme-staging-v02.api.letsencrypt.org/directory&lt;/code&gt;. Staging has much more generous rate limits, so get everything working there first. The token secret lives in the &lt;code&gt;cert-manager&lt;/code&gt; namespace because, for a ClusterIssuer, cert-manager looks for referenced secrets in its own namespace by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: one wildcard for every internal name
&lt;/h2&gt;

&lt;p&gt;You could request one certificate per app. A single wildcard for &lt;code&gt;*.home.example.com&lt;/code&gt; is simpler and has a privacy benefit: every certificate Let's Encrypt issues is published in public Certificate Transparency logs, and a wildcard publishes &lt;code&gt;*.home.example.com&lt;/code&gt; instead of a list of every app you run. Wildcards can only be issued through DNS-01, which is one more reason to use it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Certificate&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wildcard-home&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wildcard-home-tls&lt;/span&gt;
  &lt;span class="na"&gt;issuerRef&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;letsencrypt-staging&lt;/span&gt;        &lt;span class="c1"&gt;# switch to letsencrypt-prod when it works&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClusterIssuer&lt;/span&gt;
  &lt;span class="na"&gt;dnsNames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;home.example.com"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.home.example.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; wildcard.yaml
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system get certificate wildcard-home &lt;span class="nt"&gt;-w&lt;/span&gt;     &lt;span class="c"&gt;# READY becomes True&lt;/span&gt;
kubectl describe certificaterequest &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system          &lt;span class="c"&gt;# if it does not&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the staging certificate shows &lt;code&gt;Ready&lt;/code&gt;, change &lt;code&gt;issuerRef.name&lt;/code&gt; to &lt;code&gt;letsencrypt-prod&lt;/code&gt;, apply again, and delete the old secret so cert-manager issues a fresh one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: make it Traefik's default certificate
&lt;/h2&gt;

&lt;p&gt;The certificate lives in &lt;code&gt;kube-system&lt;/code&gt;, but an Ingress can only reference Secrets in its own namespace. Instead of copying the secret into every namespace, make it Traefik's default. Traefik serves the default certificate for any TLS connection that no more specific certificate matches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;traefik.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TLSStore&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kube-system&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;defaultCertificate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;wildcard-home-tls&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now any Ingress under &lt;code&gt;home.example.com&lt;/code&gt; that asks for TLS gets the wildcard automatically, with no &lt;code&gt;secretName&lt;/code&gt; at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;hosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;whoami.home.example.com"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;whoami.home.example.com&lt;/span&gt;
      &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
            &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
            &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;service&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;whoami&lt;/span&gt;
                &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plain Ingress is a fine choice here. The k3s documentation states that the traditional Ingress API is still supported and not planned for deprecation, and most Helm charts still generate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep internal names internal
&lt;/h2&gt;

&lt;p&gt;DNS-01 means the certificate is public but the service is not. Nothing in public DNS points at your lab; the only public record cert-manager creates is a temporary TXT record during issuance. &lt;code&gt;*.home.example.com&lt;/code&gt; resolves only through your LAN resolver's override to a private address, so from outside your network the names do not resolve and the address is unreachable.&lt;/p&gt;

&lt;p&gt;For access from outside the house, use a VPN instead of a port forward. Once your phone is connected, it uses the LAN resolver and every app works exactly as it does at home.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;kubectl get ingressclass&lt;/code&gt; shows &lt;code&gt;traefik&lt;/code&gt;, and Traefik settings live in one &lt;code&gt;HelmChartConfig&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Plain HTTP redirects to HTTPS.&lt;/li&gt;
&lt;li&gt;cert-manager is installed with &lt;code&gt;crds.enabled=true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The DNS provider credential is a scoped API token, stored in the &lt;code&gt;cert-manager&lt;/code&gt; namespace.&lt;/li&gt;
&lt;li&gt;Staging and production ClusterIssuers exist, and you proved the setup on staging first.&lt;/li&gt;
&lt;li&gt;One wildcard &lt;code&gt;Certificate&lt;/code&gt; is &lt;code&gt;Ready&lt;/code&gt; and set as Traefik's default in a &lt;code&gt;TLSStore&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No port is forwarded from the internet; remote access goes through a VPN.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is adapted from my book &lt;em&gt;Kubernetes Without the Overwhelm&lt;/em&gt; (Homelab the Right Way, Book 12). The free sample is &lt;a href="https://payhip.com/b/XGIMw" rel="noopener noreferrer"&gt;here&lt;/a&gt; and the full book is &lt;a href="https://payhip.com/b/uyY03" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Want the week's homelab articles, videos and one tip from the books in your inbox every Sunday? &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/newsletter/" rel="noopener noreferrer"&gt;Get the free weekly email&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI help and edited by the author; based on his book.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>devops</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Your app starts slow after every update: generate a Baseline Profile and prove it is applied</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Fri, 23 Oct 2026 14:21:08 +0000</pubDate>
      <link>https://dev.to/charleshartmann/your-app-starts-slow-after-every-update-generate-a-baseline-profile-and-prove-it-is-applied-4f9b</link>
      <guid>https://dev.to/charleshartmann/your-app-starts-slow-after-every-update-generate-a-baseline-profile-and-prove-it-is-applied-4f9b</guid>
      <description>&lt;p&gt;A Baseline Profile is the single change most likely to make an existing Compose app start faster and scroll more smoothly without touching its logic. It is a list of the classes and methods your app needs on its most important paths, shipped inside the app, so the runtime can compile them ahead of time on install instead of discovering them slowly on the user's phone. This post generates one with the Baseline Profile Gradle plugin and then proves, three ways, that it is applied.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;Your Kotlin compiles to DEX bytecode. The Android Runtime (ART) can interpret it, JIT-compile hot methods while the app runs, or run machine code compiled ahead of time (AOT). ART does not AOT-compile your whole app on install. It records which methods were used into a profile and compiles them later, when the device is idle and charging.&lt;/p&gt;

&lt;p&gt;That works eventually, and "eventually" is the problem. After every install and every update, users start in the slow path until the device has recorded enough and found a quiet moment to compile it. Google Play's Cloud Profiles help later installs of a release, but they need time and users to build up, so they do nothing for the first users of each new version.&lt;/p&gt;

&lt;p&gt;Jetpack libraries, Compose included, ship their own profiles, which is why a plain Compose app already benefits a little. Library profiles only cover library code, though. Your screens, ViewModels and repositories are in nobody's profile but yours. Google's documentation states that Baseline Profiles improve code execution speed by about 30% from the first launch for the code paths they include; your own gain depends on your app, and Macrobenchmark tells you what it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: the generator module
&lt;/h2&gt;

&lt;p&gt;The Baseline Profile Gradle plugin (&lt;code&gt;androidx.baselineprofile&lt;/code&gt;, 1.5.0) does the plumbing. Android Studio's Baseline Profile Generator module template (File &amp;gt; New &amp;gt; New Module, Android Studio Iguana or newer, AGP 8.2 or newer) writes this for you. The generator is a &lt;code&gt;com.android.test&lt;/code&gt; module that points at the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// baselineprofile/build.gradle.kts&lt;/span&gt;
&lt;span class="nf"&gt;plugins&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.android.test"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.jetbrains.kotlin.android"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"androidx.baselineprofile"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;android&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.example.fieldnotes.baselineprofile"&lt;/span&gt;
    &lt;span class="n"&gt;compileSdk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;36&lt;/span&gt;
    &lt;span class="nf"&gt;defaultConfig&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;minSdk&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;
        &lt;span class="n"&gt;testInstrumentationRunner&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"androidx.test.runner.AndroidJUnitRunner"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;targetProjectPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;":app"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"androidx.benchmark:benchmark-macro-junit4:1.5.0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"androidx.test.uiautomator:uiautomator:2.4.0"&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;Generation needs root on the device, except on Android 13 (API 33) and newer, where Macrobenchmark can collect profiles without root. A Gradle-managed device with &lt;code&gt;systemImageSource = "aosp"&lt;/code&gt; works everywhere because AOSP images allow root.&lt;/p&gt;

&lt;p&gt;The app module applies the same plugin, depends on the generator, and includes ProfileInstaller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/build.gradle.kts&lt;/span&gt;
&lt;span class="nf"&gt;plugins&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.android.application"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.jetbrains.kotlin.android"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"androidx.baselineprofile"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"androidx.profileinstaller:profileinstaller:1.4.1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;baselineProfile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;":baselineprofile"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;baselineProfile&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;saveInSrc&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;      &lt;span class="c1"&gt;// commit the generated profile&lt;/span&gt;
    &lt;span class="n"&gt;mergeIntoMain&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;// one profile for all variants&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ProfileInstaller matters for installs that do not come from Google Play: sideloaded builds, internal testing installs and other stores. There, Play is not around to hand the profile to ART, so ProfileInstaller writes it on the app's first launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: write the journeys
&lt;/h2&gt;

&lt;p&gt;The generator is an instrumented test with &lt;code&gt;BaselineProfileRule&lt;/code&gt;. It drives the app through the paths users take every day, and the rule records every class and method that ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@RunWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AndroidJUnit4&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nd"&gt;@LargeTest&lt;/span&gt;
&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;BaselineProfileGenerator&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nc"&gt;Rule&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BaselineProfileRule&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="nd"&gt;@Test&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;packageName&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.example.fieldnotes"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;includeInStartupProfile&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;pressHome&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nf"&gt;startActivityAndWait&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Until&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;By&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;res&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"notes_list"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="mi"&gt;5_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;list&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;By&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;res&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"notes_list"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setGestureMargin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;displayWidth&lt;/span&gt; &lt;span class="p"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fling&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Direction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DOWN&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForIdle&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;By&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;res&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"note_row"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Until&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasObject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;By&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;res&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"editor_body"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="mi"&gt;5_000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pressBack&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test finds elements by resource id, so in Compose expose test tags as resource ids once near the root with &lt;code&gt;Modifier.semantics { testTagsAsResourceId = true }&lt;/code&gt; and tag the elements the generator needs with &lt;code&gt;Modifier.testTag("notes_list")&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Good journeys follow a few rules. Cover what users actually do, most common first. Wait for content, because if the journey moves on before the list loads, the list's code is not in the profile. Keep it deterministic with local or seeded data rather than a live server. And never sign in to real accounts from a generator.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: generate and commit
&lt;/h2&gt;

&lt;p&gt;Run the plugin's task, not the test directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./gradlew :app:generateBaselineProfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin builds the app, runs the generator and copies the result into your sources:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/src/main/generated/baselineProfiles/baseline-prof.txt
app/src/main/generated/baselineProfiles/startup-prof.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Commit both. Every release build compiles them into the app, and you will find the binary form under &lt;code&gt;assets/dexopt/baseline.prof&lt;/code&gt; in the AAB or APK. The rules inside are plain text: &lt;code&gt;HSPLcom/example/fieldnotes/ui/NotesViewModel;-&amp;gt;loadNotes()V&lt;/code&gt;, where H means hot, S startup and P post-startup.&lt;/p&gt;

&lt;p&gt;The second file is a Startup Profile, produced because of &lt;code&gt;includeInStartupProfile = true&lt;/code&gt;. It tells R8 to put every class needed at startup into the primary DEX file. It needs R8 enabled for release (&lt;code&gt;isMinifyEnabled = true&lt;/code&gt;) and DEX layout optimization, which is on by default from AGP 8.3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: prove it is applied
&lt;/h2&gt;

&lt;p&gt;A profile that does not reach the device is just a file in a repository.&lt;/p&gt;

&lt;p&gt;Benchmark it. Compare a fresh install with no ahead-of-time compilation against one that requires your profile and fails loudly if the app has none:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Test&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;startupNoCompilation&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;startup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;CompilationMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="nd"&gt;@Test&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;startupBaselineProfile&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;startup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nc"&gt;CompilationMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Partial&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;baselineProfileMode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BaselineProfileMode&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Require&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run both on the same physical device and compare the median &lt;code&gt;timeToInitialDisplayMs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Check the device by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PKG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;com.example.fieldnotes
adb &lt;span class="nb"&gt;install &lt;/span&gt;app-release.apk
adb shell am broadcast &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-a&lt;/span&gt; androidx.profileinstaller.action.INSTALL_PROFILE &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;$PKG&lt;/span&gt;/androidx.profileinstaller.ProfileInstallReceiver
adb shell am force-stop &lt;span class="nv"&gt;$PKG&lt;/span&gt;
adb shell cmd package compile &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; speed-profile &lt;span class="nv"&gt;$PKG&lt;/span&gt;
adb shell dumpsys package dexopt | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; 1 &lt;span class="nv"&gt;$PKG&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The broadcast prints "1" on success, and the dexopt status should read &lt;code&gt;speed-profile&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Ask from inside the app. &lt;code&gt;ProfileVerifier.getCompilationStatusAsync()&lt;/code&gt; reports whether the profile was installed and compiled (&lt;code&gt;isCompiledWithProfile&lt;/code&gt;, &lt;code&gt;hasProfileEnqueuedForCompilation()&lt;/code&gt;). Log it in internal builds; "enqueued" is normal right after an install from outside Play.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The generator module applies &lt;code&gt;androidx.baselineprofile&lt;/code&gt; and targets &lt;code&gt;:app&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The app depends on ProfileInstaller and on the generator through &lt;code&gt;baselineProfile(...)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Journeys cover launch, the main list, opening an item, and wait for content.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;baseline-prof.txt&lt;/code&gt; and &lt;code&gt;startup-prof.txt&lt;/code&gt; are committed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;None&lt;/code&gt; versus &lt;code&gt;Partial(Require)&lt;/code&gt; shows a measured gap on a real device.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dumpsys package dexopt&lt;/code&gt; reads &lt;code&gt;speed-profile&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The profile is regenerated after screen redesigns, major Compose upgrades and before releases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is adapted from my book &lt;em&gt;Fast by Default&lt;/em&gt; (Android the Right Way, Book 7). The free sample is &lt;a href="https://payhip.com/b/rsUtW" rel="noopener noreferrer"&gt;here&lt;/a&gt; and the full book is &lt;a href="https://payhip.com/b/4nGE6" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Want the week's homelab articles, videos and one tip from the books in your inbox every Sunday? &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/newsletter/" rel="noopener noreferrer"&gt;Get the free weekly email&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI help and edited by the author; based on his book.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>performance</category>
      <category>jetpackcompose</category>
    </item>
    <item>
      <title>Snapshots on a schedule, replicas on another box: sanoid and syncoid pull replication for ZFS</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Thu, 22 Oct 2026 14:05:51 +0000</pubDate>
      <link>https://dev.to/charleshartmann/snapshots-on-a-schedule-replicas-on-another-box-sanoid-and-syncoid-pull-replication-for-zfs-119p</link>
      <guid>https://dev.to/charleshartmann/snapshots-on-a-schedule-replicas-on-another-box-sanoid-and-syncoid-pull-replication-for-zfs-119p</guid>
      <description>&lt;p&gt;A ZFS snapshot is a read-only picture of a dataset at one instant. It takes about a second to create and costs no space at first. Snapshots on a schedule, thinned automatically and copied to a second machine, are the single biggest upgrade most homelab storage can get. This post builds exactly that with two tools from the same project: sanoid takes and prunes snapshots, syncoid replicates them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap
&lt;/h2&gt;

&lt;p&gt;Homelab storage usually fails in one of two opposite ways. The first lab has no snapshots at all, so an accidental &lt;code&gt;rm -rf&lt;/code&gt; on a share is permanent. The second takes snapshots every fifteen minutes and never prunes them: ten thousand of them, a pool that is mysteriously full, and a &lt;code&gt;zfs list -t snapshot&lt;/code&gt; that takes a minute to answer.&lt;/p&gt;

&lt;p&gt;Both labs also believe the snapshots are their backup. They are not. A snapshot lives in the same pool as the data. If the pool dies (two disks in a RAIDZ1, a controller that writes garbage, a mistaken &lt;code&gt;zpool destroy&lt;/code&gt;), the snapshots die with it. Snapshots protect against changes: deletions, overwrites, bad upgrades, ransomware on a share. Backups protect against loss. You need both, and replication to a second box gets you most of the way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why snapshots are cheap
&lt;/h2&gt;

&lt;p&gt;ZFS never overwrites data in place. When a block changes, the new version goes to a new location and the dataset's pointers move to it. A snapshot simply keeps the old pointers, so until something changes the snapshot and the live dataset share every block.&lt;/p&gt;

&lt;p&gt;That also tells you where snapshot space goes. A snapshot grows as the live data moves away from it: delete a 20 GB folder and the space is not freed until every snapshot that still references those blocks is destroyed. When a pool fills up unexpectedly, this command sorts snapshots by the space only they hold, and the culprit is usually at the bottom:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zfs list &lt;span class="nt"&gt;-t&lt;/span&gt; snapshot &lt;span class="nt"&gt;-o&lt;/span&gt; name,used &lt;span class="nt"&gt;-s&lt;/span&gt; used
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: a retention policy with sanoid
&lt;/h2&gt;

&lt;p&gt;Debian and Proxmox VE package sanoid, and the package ships a systemd timer that runs it every 15 minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;sanoid
systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; sanoid.timer
systemctl list-timers | &lt;span class="nb"&gt;grep &lt;/span&gt;sanoid
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The policy lives in &lt;code&gt;/etc/sanoid/sanoid.conf&lt;/code&gt;: datasets at the top, templates at the bottom. A trimmed version:&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;[tank/documents]&lt;/span&gt;
    &lt;span class="py"&gt;use_template&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;important&lt;/span&gt;
&lt;span class="nn"&gt;[tank/shares]&lt;/span&gt;
    &lt;span class="py"&gt;use_template&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;standard&lt;/span&gt;
    &lt;span class="py"&gt;recursive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="nn"&gt;[tank/media]&lt;/span&gt;
    &lt;span class="py"&gt;use_template&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;light&lt;/span&gt;

&lt;span class="nn"&gt;[template_important]&lt;/span&gt;
    &lt;span class="py"&gt;frequently&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
    &lt;span class="py"&gt;hourly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;48&lt;/span&gt;
    &lt;span class="py"&gt;daily&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;30&lt;/span&gt;
    &lt;span class="py"&gt;weekly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8&lt;/span&gt;
    &lt;span class="py"&gt;monthly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;12&lt;/span&gt;
    &lt;span class="py"&gt;yearly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
    &lt;span class="py"&gt;autosnap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
    &lt;span class="py"&gt;autoprune&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;

&lt;span class="nn"&gt;[template_standard]&lt;/span&gt;
    &lt;span class="py"&gt;hourly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;24&lt;/span&gt;
    &lt;span class="py"&gt;daily&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;14&lt;/span&gt;
    &lt;span class="py"&gt;weekly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;4&lt;/span&gt;
    &lt;span class="py"&gt;monthly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;3&lt;/span&gt;
    &lt;span class="py"&gt;autosnap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
    &lt;span class="py"&gt;autoprune&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;

&lt;span class="nn"&gt;[template_light]&lt;/span&gt;
    &lt;span class="py"&gt;hourly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
    &lt;span class="py"&gt;daily&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;7&lt;/span&gt;
    &lt;span class="py"&gt;weekly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
    &lt;span class="py"&gt;monthly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0&lt;/span&gt;
    &lt;span class="py"&gt;autosnap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
    &lt;span class="py"&gt;autoprune&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each dataset section names a template, and &lt;code&gt;recursive = yes&lt;/code&gt; applies it to every child dataset. The numbers are how many snapshots of each kind to keep. Recent history is dense (48 hourlies) and older history is sparse (12 monthlies), so about a hundred snapshots cover a full year, and sanoid deletes each one when it falls off the end of its rung.&lt;/p&gt;

&lt;p&gt;Anything you leave out comes from &lt;code&gt;/etc/sanoid/sanoid.defaults.conf&lt;/code&gt;, which you should not edit, so set every interval explicitly in your own templates.&lt;/p&gt;

&lt;p&gt;Test the policy without touching anything, then take a round of snapshots and look:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sanoid &lt;span class="nt"&gt;--cron&lt;/span&gt; &lt;span class="nt"&gt;--readonly&lt;/span&gt; &lt;span class="nt"&gt;--verbose&lt;/span&gt;      &lt;span class="c"&gt;# simulate&lt;/span&gt;
sanoid &lt;span class="nt"&gt;--take-snapshots&lt;/span&gt; &lt;span class="nt"&gt;--verbose&lt;/span&gt;       &lt;span class="c"&gt;# take snapshots now&lt;/span&gt;
zfs list &lt;span class="nt"&gt;-t&lt;/span&gt; snapshot &lt;span class="nt"&gt;-o&lt;/span&gt; name,used &lt;span class="nt"&gt;-s&lt;/span&gt; creation tank/documents | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One rule if the box also runs Proxmox guests: pick one snapshot owner per dataset. Proxmox's own VM snapshots and storage replication create and delete snapshots on the same zvols. If Proxmox replication or vzdump with PBS already covers your guests, leave &lt;code&gt;tank/vm&lt;/code&gt; out of sanoid, or keep its policy very light.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: how incremental replication works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;zfs send&lt;/code&gt; turns a snapshot into a stream of bytes; &lt;code&gt;zfs receive&lt;/code&gt; turns that stream back into a dataset on another pool. The first send is full. Every later send is incremental: only the blocks that changed between two snapshots travel. Both sides must keep the last common snapshot, or the next incremental has nothing to build on.&lt;/p&gt;

&lt;p&gt;By hand it looks like this, with &lt;code&gt;nas&lt;/code&gt; and &lt;code&gt;backup&lt;/code&gt; as SSH host aliases with key-based login:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;zfs snapshot tank/documents@rep-1
zfs send tank/documents@rep-1 | ssh backup zfs receive &lt;span class="nt"&gt;-u&lt;/span&gt; backup/documents
zfs snapshot tank/documents@rep-2
zfs send &lt;span class="nt"&gt;-i&lt;/span&gt; @rep-1 tank/documents@rep-2 | ssh backup zfs receive &lt;span class="nt"&gt;-u&lt;/span&gt; backup/documents
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;zfs receive -u&lt;/code&gt; does not mount the received dataset, which avoids mountpoint clashes on the target. Tracking the last common snapshot for every dataset, every night, is the part nobody keeps up with. syncoid does it for you: it finds the newest common snapshot, sends the incremental, resumes interrupted transfers when both sides support it, and cleans up its own snapshots.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: pull, do not push
&lt;/h2&gt;

&lt;p&gt;A backup box that pulls from the NAS is safer than a NAS that pushes to the backup box. If the NAS is compromised, an attacker holding its SSH key can delete everything on a push target. With pull replication, the NAS has no credentials for the backup box at all.&lt;/p&gt;

&lt;p&gt;On the backup box, a small script run by cron at 01:30:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /usr/local/sbin/pull-replicas &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/sh
S="/usr/sbin/syncoid --recursive --no-sync-snap --quiet"
&lt;/span&gt;&lt;span class="nv"&gt;$S&lt;/span&gt;&lt;span class="sh"&gt; nas:tank/photos    backup/photos
&lt;/span&gt;&lt;span class="nv"&gt;$S&lt;/span&gt;&lt;span class="sh"&gt; nas:tank/documents backup/documents
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/local/sbin/pull-replicas
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'30 1 * * * root /usr/local/sbin/pull-replicas'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/cron.d/pull-replicas
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default syncoid creates its own short-lived &lt;code&gt;syncoid_&lt;/code&gt; snapshot for each run. &lt;code&gt;--no-sync-snap&lt;/code&gt; tells it to use the snapshots sanoid already took, which keeps the target's history identical to the source. &lt;code&gt;--recursive&lt;/code&gt; includes child datasets. For an encrypted dataset, add &lt;code&gt;--sendoptions=w&lt;/code&gt; for a raw send: the blocks travel and are stored still encrypted, and the backup box never needs the key. Keep every passphrase somewhere that does not depend on the pool it unlocks.&lt;/p&gt;

&lt;p&gt;The received datasets still need pruning. On the backup box, give them a template that never takes snapshots and only prunes what replication brings in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[backup/photos]&lt;/span&gt;
    &lt;span class="py"&gt;use_template&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;backup&lt;/span&gt;
    &lt;span class="py"&gt;recursive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
&lt;span class="nn"&gt;[backup/documents]&lt;/span&gt;
    &lt;span class="py"&gt;use_template&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;backup&lt;/span&gt;
    &lt;span class="py"&gt;recursive&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;

&lt;span class="nn"&gt;[template_backup]&lt;/span&gt;
    &lt;span class="py"&gt;autosnap&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;no&lt;/span&gt;
    &lt;span class="py"&gt;autoprune&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;yes&lt;/span&gt;
    &lt;span class="py"&gt;hourly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;48&lt;/span&gt;
    &lt;span class="py"&gt;daily&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;30&lt;/span&gt;
    &lt;span class="py"&gt;weekly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;8&lt;/span&gt;
    &lt;span class="py"&gt;monthly&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;12&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For tighter security, replicate as a non-root user with ZFS permission delegation: on the NAS, &lt;code&gt;zfs allow -u replicator send,hold,snapshot tank&lt;/code&gt;, and run syncoid on the backup box with &lt;code&gt;--no-privilege-elevation&lt;/code&gt;. The receiving user needs &lt;code&gt;create,mount,receive&lt;/code&gt; on the target dataset. Test the delegation once by hand before relying on it.&lt;/p&gt;

&lt;p&gt;A TrueNAS box works as the target too. It speaks standard ZFS over SSH, so the same syncoid commands work; create the destination parent dataset in the TrueNAS UI first and let TrueNAS own the retention for those datasets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: prove it
&lt;/h2&gt;

&lt;p&gt;Replication you have never restored from is a hope, not a plan. Once a month, restore one real file from the replica and compare checksums from both machines:&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 the backup box&lt;/span&gt;
zfs clone backup/documents@autosnap_2026-10-16_00:00:02_daily backup/restore-test
zfs &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;mountpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/mnt/restore-test backup/restore-test
&lt;span class="nb"&gt;sha256sum&lt;/span&gt; /mnt/restore-test/taxes/2025.pdf
ssh nas &lt;span class="nb"&gt;sha256sum&lt;/span&gt; /tank/documents/taxes/2025.pdf
zfs destroy backup/restore-test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Matching checksums are the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every dataset that matters has a sanoid template, and &lt;code&gt;tank/vm&lt;/code&gt; has exactly one snapshot owner.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sanoid.timer&lt;/code&gt; is enabled and &lt;code&gt;zfs list -t snapshot&lt;/code&gt; shows recent autosnaps.&lt;/li&gt;
&lt;li&gt;A second box receives incremental replication, ideally by pulling.&lt;/li&gt;
&lt;li&gt;Encrypted datasets replicate raw, and the keys are stored off the pool.&lt;/li&gt;
&lt;li&gt;The backup box prunes with a &lt;code&gt;backup&lt;/code&gt; template (&lt;code&gt;autosnap = no&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;You have restored one file from the replica and compared checksums.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is adapted from my book &lt;em&gt;Store It Right&lt;/em&gt; (Homelab the Right Way, Book 11). The free sample is &lt;a href="https://payhip.com/b/6wIkQ" rel="noopener noreferrer"&gt;here&lt;/a&gt; and the full book is &lt;a href="https://payhip.com/b/d2XuW" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Want the week's homelab articles, videos and one tip from the books in your inbox every Sunday? &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/newsletter/" rel="noopener noreferrer"&gt;Get the free weekly email&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI help and edited by the author; based on his book.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>zfs</category>
      <category>homelab</category>
      <category>selfhosted</category>
      <category>linux</category>
    </item>
    <item>
      <title>Build a Proxmox cloud-init template from the official Debian 13 image (and clone VMs that configure themselves)</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Tue, 20 Oct 2026 14:02:31 +0000</pubDate>
      <link>https://dev.to/charleshartmann/build-a-proxmox-cloud-init-template-from-the-official-debian-13-image-and-clone-vms-that-configure-4eb1</link>
      <guid>https://dev.to/charleshartmann/build-a-proxmox-cloud-init-template-from-the-official-debian-13-image-and-clone-vms-that-configure-4eb1</guid>
      <description>&lt;p&gt;Every VM in a rebuildable lab starts from the same place: a template made from a distribution's official cloud image. Cloud images are small, minimal installs built to be configured on first boot by cloud-init, which reads its settings from a little drive that Proxmox attaches to the VM. That is how a fresh clone wakes up with the right hostname, your user, your SSH key and its address, without anyone touching a console.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pick the image
&lt;/h2&gt;

&lt;p&gt;Debian publishes cloud images at cloud.debian.org. At the time of writing (October 2026) the latest folder for trixie (Debian 13) holds, among others:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;debian-13-genericcloud-amd64.qcow2&lt;/code&gt;: for cloud and virtual machines, with a smaller set of hardware drivers. This is the one for Proxmox VMs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;debian-13-generic-amd64.qcow2&lt;/code&gt;: the same system with a wider set of drivers, for odd hardware.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ubuntu publishes its own cloud images at cloud-images.ubuntu.com, and the Proxmox documentation uses one in its example. The commands below are the same for either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the template by hand, once
&lt;/h2&gt;

&lt;p&gt;Run these on the Proxmox host. The steps follow the Proxmox VE Cloud-Init Support documentation; VM ID 9000 and the storage names are examples.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /var/lib/vz/template/iso
wget https://cloud.debian.org/images/cloud/trixie/latest/&lt;span class="se"&gt;\&lt;/span&gt;
debian-13-genericcloud-amd64.qcow2

qm create 9000 &lt;span class="nt"&gt;--name&lt;/span&gt; debian13-tmpl &lt;span class="nt"&gt;--memory&lt;/span&gt; 2048 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cores&lt;/span&gt; 2 &lt;span class="nt"&gt;--net0&lt;/span&gt; virtio,bridge&lt;span class="o"&gt;=&lt;/span&gt;vmbr0 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--scsihw&lt;/span&gt; virtio-scsi-pci &lt;span class="nt"&gt;--ostype&lt;/span&gt; l26 &lt;span class="nt"&gt;--agent&lt;/span&gt; &lt;span class="nv"&gt;enabled&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
qm &lt;span class="nb"&gt;set &lt;/span&gt;9000 &lt;span class="nt"&gt;--scsi0&lt;/span&gt; local-lvm:0,import-from&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
/var/lib/vz/template/iso/debian-13-genericcloud-amd64.qcow2
qm &lt;span class="nb"&gt;set &lt;/span&gt;9000 &lt;span class="nt"&gt;--ide2&lt;/span&gt; local-lvm:cloudinit
qm &lt;span class="nb"&gt;set &lt;/span&gt;9000 &lt;span class="nt"&gt;--boot&lt;/span&gt; &lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;scsi0
qm &lt;span class="nb"&gt;set &lt;/span&gt;9000 &lt;span class="nt"&gt;--serial0&lt;/span&gt; socket &lt;span class="nt"&gt;--vga&lt;/span&gt; serial0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each step does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;qm create&lt;/code&gt; makes an empty VM with a VirtIO network card on bridge vmbr0 and the VirtIO SCSI controller that cloud images expect.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;import-from&lt;/code&gt; copies the downloaded image into a new disk on local-lvm and attaches it as scsi0.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--ide2 ...:cloudinit&lt;/code&gt; adds the small cloud-init drive. Proxmox writes the user, network and SSH settings onto it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--boot order=scsi0&lt;/code&gt; boots straight from the disk, skipping the CD-ROM check.&lt;/li&gt;
&lt;li&gt;The serial console is what many cloud images expect as their display. The Proxmox docs note that if a particular image does not work with it, you switch back to the default display.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Set the cloud-init defaults
&lt;/h2&gt;

&lt;p&gt;Settings made on the template are inherited by every clone, so put the parts that are the same everywhere here:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qm &lt;span class="nb"&gt;set &lt;/span&gt;9000 &lt;span class="nt"&gt;--ciuser&lt;/span&gt; admin
qm &lt;span class="nb"&gt;set &lt;/span&gt;9000 &lt;span class="nt"&gt;--sshkeys&lt;/span&gt; /root/admin_ed25519.pub
qm &lt;span class="nb"&gt;set &lt;/span&gt;9000 &lt;span class="nt"&gt;--ipconfig0&lt;/span&gt; &lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dhcp
qm &lt;span class="nb"&gt;set &lt;/span&gt;9000 &lt;span class="nt"&gt;--searchdomain&lt;/span&gt; home.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--nameserver&lt;/span&gt; 192.168.20.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--sshkeys&lt;/code&gt; takes a file with one public key per line. Use keys, not &lt;code&gt;--cipassword&lt;/code&gt;; the Proxmox documentation itself says passwords are generally not recommended here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Add your own first-boot steps
&lt;/h2&gt;

&lt;p&gt;The most common extra is the QEMU guest agent: the Debian cloud image does not include it, and without it Proxmox (and any tooling that asks Proxmox) cannot read the VM's address or shut it down cleanly.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--cicustom&lt;/code&gt; option lets you supply your own files in place of the generated ones. It accepts user, network, meta and vendor files. Replacing &lt;code&gt;user&lt;/code&gt; throws away the user and SSH key settings above, so add a &lt;strong&gt;vendor&lt;/strong&gt; file instead: cloud-init merges it with the generated user data.&lt;/p&gt;

&lt;p&gt;Custom files must live on a storage that allows the snippets content type. On the default local storage that is &lt;code&gt;/var/lib/vz/snippets/&lt;/code&gt;. Enable the content type under Datacenter &amp;gt; Storage &amp;gt; local &amp;gt; Content, then create the file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;#cloud-config&lt;/span&gt;
&lt;span class="c1"&gt;# /var/lib/vz/snippets/debian-vendor.yaml&lt;/span&gt;
&lt;span class="na"&gt;package_update&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;qemu-guest-agent&lt;/span&gt;
&lt;span class="na"&gt;runcmd&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;systemctl enable --now qemu-guest-agent&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Attach it to the template and look at what a clone will receive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qm &lt;span class="nb"&gt;set &lt;/span&gt;9000 &lt;span class="nt"&gt;--cicustom&lt;/span&gt; &lt;span class="s2"&gt;"vendor=local:snippets/debian-vendor.yaml"&lt;/span&gt;
qm cloudinit dump 9000 user
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch out: the Proxmox docs point out that custom cloud-init files must be available on every node a VM may migrate to, or the VM will not start there. In a cluster, use shared storage for snippets or copy the file to every node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turn it into a template
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;qm template 9000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A template cannot be started; it can only be cloned. That is the point: nobody logs into it, so it never drifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Clone and personalize
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;qm clone 9000 120 &lt;span class="nt"&gt;--name&lt;/span&gt; docker01
qm &lt;span class="nb"&gt;set &lt;/span&gt;120 &lt;span class="nt"&gt;--ipconfig0&lt;/span&gt; &lt;span class="nv"&gt;ip&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.20.120/24,gw&lt;span class="o"&gt;=&lt;/span&gt;192.168.20.1
qm disk resize 120 scsi0 20G
qm start 120
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After a minute, &lt;code&gt;ssh admin@192.168.20.120&lt;/code&gt; should work with your key. The cloud image's disk is small, which is why the clone's disk is grown with &lt;code&gt;qm disk resize&lt;/code&gt;; cloud-init grows the root filesystem to fill it on boot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Linked or full clones
&lt;/h2&gt;

&lt;p&gt;When you clone a template, Proxmox tries to make a linked clone by default: the new disk only stores the differences from the template's disk. It is fast and small. The catch is that the clone depends on the template; you cannot delete the template while linked clones exist, and not every storage type supports them. Pass &lt;code&gt;--full&lt;/code&gt; to get an independent copy.&lt;/p&gt;

&lt;p&gt;For a rebuildable lab, full clones are usually the better default. The template will be rebuilt whenever a new Debian point release arrives, and full clones do not care.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Debian 13 genericcloud image imported, cloud-init drive added, serial console set.&lt;/li&gt;
&lt;li&gt;User, SSH key, DNS and search domain set on the template, not on each clone.&lt;/li&gt;
&lt;li&gt;A vendor snippet installs qemu-guest-agent; &lt;code&gt;qm cloudinit dump&lt;/code&gt; shows it merged in.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;qm template&lt;/code&gt; run; clones made with &lt;code&gt;--full&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The snippet is kept in your repository; the copy on the Proxmox host is just a deployment of it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is adapted from my book &lt;em&gt;Automate Your Homelab&lt;/em&gt; (Homelab the Right Way, Book 9). The free sample is &lt;a href="https://payhip.com/b/yql34" rel="noopener noreferrer"&gt;here&lt;/a&gt; and the full book is &lt;a href="https://payhip.com/b/ZcsXi" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Want the week's homelab articles, videos and one tip from the books in your inbox every Sunday? &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/newsletter/" rel="noopener noreferrer"&gt;Get the free weekly email&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI help and edited by the author; based on his book.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>proxmox</category>
      <category>homelab</category>
      <category>linux</category>
      <category>devops</category>
    </item>
    <item>
      <title>Promtail is end of life: ship your homelab's logs to Loki with Grafana Alloy instead</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Mon, 19 Oct 2026 14:03:43 +0000</pubDate>
      <link>https://dev.to/charleshartmann/promtail-is-end-of-life-ship-your-homelabs-logs-to-loki-with-grafana-alloy-instead-2dgp</link>
      <guid>https://dev.to/charleshartmann/promtail-is-end-of-life-ship-your-homelabs-logs-to-loki-with-grafana-alloy-instead-2dgp</guid>
      <description>&lt;p&gt;The default homelab log strategy is SSH and &lt;code&gt;journalctl&lt;/code&gt;, one machine at a time, after the fact. By the time you look, the container was recreated and its logs went with it, or the journal on a small root disk already rotated.&lt;/p&gt;

&lt;p&gt;The second trap is building a log stack from an old tutorial. Many still use Promtail, which reached end of life on March 2, 2026. Grafana's documentation states that it receives no further updates and that future development happens in Grafana Alloy, so new setups should start with Alloy.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the pieces fit
&lt;/h2&gt;

&lt;p&gt;Loki stores logs. Unlike a full-text search engine it indexes only a small set of labels (host, unit, container) and stores the log lines compressed in chunks, which keeps it light enough for a homelab. Alloy is the agent: it reads the journal and Docker's container logs on each host, attaches labels, and pushes the lines to Loki. Grafana queries Loki with LogQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install Alloy on each host
&lt;/h2&gt;

&lt;p&gt;Alloy runs as a native service, which makes reading the journal straightforward. The steps follow Grafana's Debian instructions (Alloy 1.20 at the time of writing, October 2026). On the Proxmox host and on each Docker host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;gpg
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/apt/keyrings
wget &lt;span class="nt"&gt;-O&lt;/span&gt; /etc/apt/keyrings/grafana.asc https://apt.grafana.com/gpg-full.key
&lt;span class="nb"&gt;chmod &lt;/span&gt;644 /etc/apt/keyrings/grafana.asc
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [signed-by=/etc/apt/keyrings/grafana.asc] &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
https://apt.grafana.com stable main"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/apt/sources.list.d/grafana.list
apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt-get &lt;span class="nb"&gt;install &lt;/span&gt;alloy
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; adm,systemd-journal alloy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line matters: Alloy's documentation warns that without membership in &lt;code&gt;adm&lt;/code&gt; and &lt;code&gt;systemd-journal&lt;/code&gt; the journal source starts without an error and collects nothing. On Docker hosts, also add the alloy user to the docker group so it can read container logs through the socket (with the usual caveat that the socket means full control of the host).&lt;/p&gt;

&lt;h2&gt;
  
  
  The configuration
&lt;/h2&gt;

&lt;p&gt;It lives in &lt;code&gt;/etc/alloy/config.alloy&lt;/code&gt; and is a set of components wired together: sources forward to a writer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="c1"&gt;// /etc/alloy/config.alloy on docker1&lt;/span&gt;
&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;endpoint&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://192.168.10.20:3100/loki/api/v1/push"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// systemd journal, with unit and level as labels&lt;/span&gt;
&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;relabel&lt;/span&gt; &lt;span class="s2"&gt;"journal"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;forward_to&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;source_labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"__journal__systemd_unit"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;target_label&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"unit"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;source_labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"__journal_priority_keyword"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;target_label&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"level"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;journal&lt;/span&gt; &lt;span class="s2"&gt;"host"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;forward_to&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;receiver&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;relabel_rules&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;relabel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;journal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rules&lt;/span&gt;
  &lt;span class="nx"&gt;labels&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"docker1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"journal"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Docker containers, labelled by container name&lt;/span&gt;
&lt;span class="nx"&gt;discovery&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docker&lt;/span&gt; &lt;span class="s2"&gt;"local"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"unix:///var/run/docker.sock"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;discovery&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;relabel&lt;/span&gt; &lt;span class="s2"&gt;"containers"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;targets&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;discovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;targets&lt;/span&gt;
  &lt;span class="nx"&gt;rule&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;source_labels&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"__meta_docker_container_name"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;regex&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/(.*)"&lt;/span&gt;
    &lt;span class="nx"&gt;target_label&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"container"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;docker&lt;/span&gt; &lt;span class="s2"&gt;"containers"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;host&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"unix:///var/run/docker.sock"&lt;/span&gt;
  &lt;span class="nx"&gt;targets&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;discovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;relabel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;containers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;
  &lt;span class="nx"&gt;forward_to&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loki&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;default&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;receiver&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;labels&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"docker1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;job&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"docker"&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;On the Proxmox host, keep the &lt;code&gt;loki.write&lt;/code&gt; and journal blocks and drop the Docker ones; the journal there carries pvedaemon, pveproxy, pvestatd, backup jobs and the kernel. Restart with &lt;code&gt;systemctl restart alloy&lt;/code&gt; and check &lt;code&gt;systemctl status alloy&lt;/code&gt;. Alloy also serves a debugging UI on port 12345 when started with a listen address, which shows each component and whether it is healthy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Labels: few and boring
&lt;/h2&gt;

&lt;p&gt;Loki is fast because its index is small. Every distinct combination of label values creates a new stream, and too many streams make Loki slow and heavy. Keep labels to things with a handful of values that you filter by: host, job, unit, container, level. Never turn request IDs, user names, IP addresses or full paths into labels; search for them in the line instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Querying with LogQL
&lt;/h2&gt;

&lt;p&gt;In Grafana, open Explore, pick the Loki data source and start with a label selector, then filter lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pve1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"pvedaemon.service"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"docker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"immich_server"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"error"&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"journal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=~&lt;/span&gt;&lt;span class="s2"&gt;"err|crit|alert|emerg"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;sum&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;count_over_time&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="na"&gt;level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;}[&lt;/span&gt;&lt;span class="mi"&gt;1h&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first shows the Proxmox API daemon's log. The second shows only lines containing "error" from one container. The third shows every high-priority journal line across the lab. The last turns logs into a metric: errors per host per hour, which makes a good panel on an overview dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't forget the local copies
&lt;/h2&gt;

&lt;p&gt;Loki gives you a searchable copy, but Docker still keeps container logs on the host, and with the default json-file driver they grow without limit unless configured. Set limits in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; on each Docker host and restart Docker (existing containers pick it up when recreated):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"log-driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"log-opts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max-size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"max-file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the hosts themselves, cap the journal too: &lt;code&gt;SystemMaxUse=500M&lt;/code&gt; in &lt;code&gt;/etc/systemd/journald.conf&lt;/code&gt;, then &lt;code&gt;systemctl restart systemd-journald&lt;/code&gt;. Loki is now the long memory; local logs only need to cover a few days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Alloy, not Promtail, on every host.&lt;/li&gt;
&lt;li&gt;The alloy user is in &lt;code&gt;adm&lt;/code&gt; and &lt;code&gt;systemd-journal&lt;/code&gt; (and &lt;code&gt;docker&lt;/code&gt; on Docker hosts).&lt;/li&gt;
&lt;li&gt;Labels: host, job, unit, container, level. Nothing high-cardinality.&lt;/li&gt;
&lt;li&gt;Docker's json-file logs capped in daemon.json; the journal capped with SystemMaxUse.&lt;/li&gt;
&lt;li&gt;A Logs panel under the graphs on your host dashboard, so a spike and its log lines sit together.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is adapted from my book &lt;em&gt;Watch Your Homelab&lt;/em&gt; (Homelab the Right Way, Book 7). The free sample is &lt;a href="https://payhip.com/b/cIY8z" rel="noopener noreferrer"&gt;here&lt;/a&gt; and the full book is &lt;a href="https://payhip.com/b/An62R" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Want the week's homelab articles, videos and one tip from the books in your inbox every Sunday? &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/newsletter/" rel="noopener noreferrer"&gt;Get the free weekly email&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI help and edited by the author; based on his book.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>grafana</category>
      <category>selfhosted</category>
      <category>devops</category>
    </item>
    <item>
      <title>Why your 2-node Proxmox cluster freezes (and the QDevice fix that is not a third server)</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Mon, 19 Oct 2026 02:49:29 +0000</pubDate>
      <link>https://dev.to/charleshartmann/why-your-2-node-proxmox-cluster-freezes-and-the-qdevice-fix-that-is-not-a-third-server-2hca</link>
      <guid>https://dev.to/charleshartmann/why-your-2-node-proxmox-cluster-freezes-and-the-qdevice-fix-that-is-not-a-third-server-2hca</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How quorum works, in one paragraph
&lt;/h2&gt;

&lt;p&gt;A Proxmox cluster uses Corosync to agree on who is alive. Each node gets one vote, and the cluster is &lt;strong&gt;quorate&lt;/strong&gt; when more than half the votes are present. Without quorum, the cluster filesystem at &lt;code&gt;/etc/pve&lt;/code&gt; goes read-only. You can't start guests, change configs or create backups, and nothing in the UI saves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: 1 vote out of 2 is not a majority
&lt;/h2&gt;

&lt;p&gt;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 &lt;em&gt;more&lt;/em&gt; than half. So the survivor freezes.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;You can see the vote count at any time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pvecm status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix: a third vote that isn't a third server
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;QDevice&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;With three votes, losing one node leaves 2 out of 3. That is more than half, so the survivor stays quorate and keeps working.&lt;/p&gt;

&lt;p&gt;The setup is three installs and one command:&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 the external box (Debian/Ubuntu/Raspberry Pi OS):&lt;/span&gt;
apt &lt;span class="nb"&gt;install &lt;/span&gt;corosync-qnetd

&lt;span class="c"&gt;# On EVERY cluster node:&lt;/span&gt;
apt &lt;span class="nb"&gt;install &lt;/span&gt;corosync-qdevice

&lt;span class="c"&gt;# On ONE cluster node (root SSH to the QDevice box must work):&lt;/span&gt;
pvecm qdevice setup 192.168.10.5

&lt;span class="c"&gt;# Check: you should see 3 total votes and "Qdevice" listed&lt;/span&gt;
pvecm status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;192.168.10.5&lt;/code&gt; with the address of your own external box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gear mentioned&lt;/strong&gt; (As an Amazon Associate I earn from qualifying purchases. Affiliate links.) &lt;a href="https://www.amazon.com/s?k=Raspberry+Pi+5+kit&amp;amp;tag=homelabrightw-20" rel="noopener noreferrer"&gt;Raspberry Pi&lt;/a&gt; · &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/posts/gear-the-books-recommend/" rel="noopener noreferrer"&gt;All the gear the books recommend&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Where the QDevice must not live
&lt;/h3&gt;

&lt;p&gt;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 &lt;strong&gt;and&lt;/strong&gt; 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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;QDevice placement&lt;/th&gt;
&lt;th&gt;One node dies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VM or container on a cluster node&lt;/td&gt;
&lt;td&gt;That node takes two votes with it; no quorum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Separate always-on box&lt;/td&gt;
&lt;td&gt;The third vote survives; the other node keeps quorum&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The related trap: a node on Wi-Fi that keeps flapping
&lt;/h2&gt;

&lt;p&gt;Even with enough votes, a cluster can look broken if one member keeps dropping in and out. The usual cause is Wi-Fi.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The symptoms look random:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The node shows a red X, then turns green again&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/etc/pve&lt;/code&gt; is briefly read-only&lt;/li&gt;
&lt;li&gt;Backup jobs fail with "cluster not ready"&lt;/li&gt;
&lt;li&gt;The logs fill with lines like &lt;code&gt;[TOTEM] A processor failed, forming new configuration&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; corosync &lt;span class="nt"&gt;--since&lt;/span&gt; &lt;span class="s2"&gt;"24 hours ago"&lt;/span&gt; | &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Ei&lt;/span&gt; &lt;span class="s2"&gt;"failed|new membership|retransmit"&lt;/span&gt;
corosync-cfgtool &lt;span class="nt"&gt;-s&lt;/span&gt;        &lt;span class="c"&gt;# link status per node&lt;/span&gt;
ping &lt;span class="nt"&gt;-c&lt;/span&gt; 100 &lt;span class="nt"&gt;-i&lt;/span&gt; 0.2 &amp;lt;other-node&amp;gt; | &lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;   &lt;span class="c"&gt;# look at mdev/max, not avg&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the &lt;code&gt;mdev&lt;/code&gt; and &lt;code&gt;max&lt;/code&gt; numbers from the ping, not the average. A good average with a big max is exactly the jitter Corosync hates.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If your nodes have two network ports, give Corosync a second link (&lt;code&gt;link1&lt;/code&gt;) on a separate network or a direct cable between the nodes. Corosync fails over between links automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gear mentioned&lt;/strong&gt; (As an Amazon Associate I earn from qualifying purchases. Affiliate links.) &lt;a href="https://www.amazon.com/s?k=MoCA+2.5+ethernet+adapter&amp;amp;tag=homelabrightw-20" rel="noopener noreferrer"&gt;Powerline or MoCA Ethernet adapter&lt;/a&gt; · &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/posts/gear-the-books-recommend/" rel="noopener noreferrer"&gt;All the gear the books recommend&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When you're stuck without quorum right now
&lt;/h2&gt;

&lt;p&gt;If one node of two is dead and you need the survivor working immediately, there is an emergency lever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pvecm expected 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scripts: wait for quorum instead of failing halfway
&lt;/h2&gt;

&lt;p&gt;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:&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="k"&gt;until &lt;/span&gt;pvecm status | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"Quorate:.*Yes"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"waiting for quorum..."&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;sleep &lt;/span&gt;10
&lt;span class="k"&gt;done
&lt;/span&gt;pct destroy 123 &lt;span class="nt"&gt;--purge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The loop waits until &lt;code&gt;pvecm status&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every cluster node is wired, ideally on the same switch&lt;/li&gt;
&lt;li&gt;Two-node clusters have a QDevice on separate hardware, never on a cluster node&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;pvecm status&lt;/code&gt; shows the expected vote count (3 for two nodes plus a QDevice)&lt;/li&gt;
&lt;li&gt;Corosync logs show no membership changes over the past day&lt;/li&gt;
&lt;li&gt;Nodes with two ports give Corosync a second link (&lt;code&gt;link1&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Scripts check for quorum before each write&lt;/li&gt;
&lt;li&gt;You know the &lt;code&gt;pvecm expected 1&lt;/code&gt; lever, and when not to pull it&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Drafted with AI help. I edited it, and it's based on my own setup.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This post is adapted from Chapter 2 of my ebook &lt;strong&gt;Homelab the Right Way: Proxmox, OPNsense &amp;amp; Home Assistant&lt;/strong&gt;. The &lt;a href="https://payhip.com/b/qon0W" rel="noopener noreferrer"&gt;free sample&lt;/a&gt; is a full chapter; the &lt;a href="https://payhip.com/b/mJKnI" rel="noopener noreferrer"&gt;whole book&lt;/a&gt; covers the cluster build, storage, backups and the network around it. All 18 of my homelab and Android ebooks are in one &lt;a href="https://payhip.com/b/aMB6c" rel="noopener noreferrer"&gt;$89 bundle&lt;/a&gt; (code RIGHTWAY20 takes 20% off through Oct 31). Get the free weekly email: &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/newsletter/" rel="noopener noreferrer"&gt;https://chartmann1590.github.io/homelab-the-right-way/newsletter/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The 60-second version is on YouTube: &lt;a href="https://youtube.com/shorts/v00ZPYLWcfM" rel="noopener noreferrer"&gt;https://youtube.com/shorts/v00ZPYLWcfM&lt;/a&gt;&lt;/p&gt;

</description>
      <category>proxmox</category>
      <category>homelab</category>
      <category>sysadmin</category>
      <category>linux</category>
    </item>
    <item>
      <title>Podcast, episode 1: The green check lies — backups, networking, self-hosting and a turtle</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Mon, 19 Oct 2026 00:57:10 +0000</pubDate>
      <link>https://dev.to/charleshartmann/podcast-episode-1-the-green-check-lies-backups-networking-self-hosting-and-a-turtle-1o8o</link>
      <guid>https://dev.to/charleshartmann/podcast-episode-1-the-green-check-lies-backups-networking-self-hosting-and-a-turtle-1o8o</guid>
      <description>&lt;p&gt;Three AI hosts, real homelab answers. The first episode of the &lt;strong&gt;Homelab the Right Way Podcast&lt;/strong&gt; is out: &lt;em&gt;Ep. 1: The green check lies — backups, networking, self-hosting and a turtle&lt;/em&gt; (46 minutes, published October 18, 2026, free).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure first:&lt;/strong&gt; Maya, Sam and Theo are fictional AI hosts. Their voices are AI-generated, the script was written with AI help and edited by a human, and every fact comes from the Homelab the Right Way books and blog. AI hosts, human-edited, facts from the books.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the episode covers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Backups: the green check lies.&lt;/strong&gt; Why the green check on a Proxmox backup job proves almost nothing, the 3-2-1 plan, &lt;code&gt;keep-daily=7&lt;/code&gt;, the backup job that silently skips new VMs, and the restore drills that tell you whether you actually have a backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage.&lt;/strong&gt; RAID is not a backup, the NFS share that fills your root disk, and &lt;code&gt;sanoid&lt;/code&gt; + &lt;code&gt;syncoid&lt;/code&gt; pull replication for ZFS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Networking.&lt;/strong&gt; What a &lt;code&gt;/24&lt;/code&gt; actually means, and why a 2-node Proxmox cluster freezes (plus the QDevice fix).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-hosting.&lt;/strong&gt; The traps around Immich, Nextcloud and Vaultwarden.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Home Assistant.&lt;/strong&gt; The motion light that turns off on you, and the Zigbee fix that beats any setting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The turtle tank.&lt;/strong&gt; The ESP32 wire that can fry the board, and the DS18B20 readings that are not temperatures.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Listen
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Episode page: &lt;a href="https://redcircle.com/shows/homelab-the-right-way/episodes/55cb28a3-0a54-4fc3-952d-70a2fedea93f" rel="noopener noreferrer"&gt;https://redcircle.com/shows/homelab-the-right-way/episodes/55cb28a3-0a54-4fc3-952d-70a2fedea93f&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Show page: &lt;a href="https://redcircle.com/shows/homelab-the-right-way" rel="noopener noreferrer"&gt;https://redcircle.com/shows/homelab-the-right-way&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;RSS feed (paste it into any podcast app): &lt;a href="https://feeds.redcircle.com/b40a097c-964d-4d5c-820f-a74bbe389dc7" rel="noopener noreferrer"&gt;https://feeds.redcircle.com/b40a097c-964d-4d5c-820f-a74bbe389dc7&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Show notes, chapters and every article mentioned: &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/posts/podcast-ep-1-the-green-check-lies/" rel="noopener noreferrer"&gt;https://chartmann1590.github.io/homelab-the-right-way/posts/podcast-ep-1-the-green-check-lies/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Spotify and Apple Podcasts listings are not live yet; the RSS feed works in any app today.&lt;/p&gt;

&lt;p&gt;Gear mentioned in the episode: the TP-Link TL-SG105 5-port gigabit switch and the Kasa Smart Plug Ultra Mini EP10. As an Amazon Associate the show earns from qualifying purchases; the links are in the show notes.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the Homelab the Right Way blog: &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/posts/podcast-ep-1-the-green-check-lies/" rel="noopener noreferrer"&gt;https://chartmann1590.github.io/homelab-the-right-way/posts/podcast-ep-1-the-green-check-lies/&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>podcast</category>
      <category>proxmox</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Your ufw rules don't cover your Docker ports (and the log-rotation setting to make before your first container)</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Sun, 18 Oct 2026 14:01:37 +0000</pubDate>
      <link>https://dev.to/charleshartmann/your-ufw-rules-dont-cover-your-docker-ports-and-the-log-rotation-setting-to-make-before-your-3bbk</link>
      <guid>https://dev.to/charleshartmann/your-ufw-rules-dont-cover-your-docker-ports-and-the-log-rotation-setting-to-make-before-your-3bbk</guid>
      <description>&lt;p&gt;Two things bite almost every new Docker host in a homelab. One is a security surprise. The other is a disk that fills up a few weeks later. Both are a five-minute fix if you do them before the first real container.&lt;/p&gt;

&lt;h2&gt;
  
  
  The firewall surprise
&lt;/h2&gt;

&lt;p&gt;Docker's own documentation on packet filtering says it plainly: Docker and ufw are incompatible. When you publish a container's port, Docker routes the traffic in the nat table, before it reaches the INPUT chain that ufw uses. Your "deny all" rule never sees it.&lt;/p&gt;

&lt;p&gt;So this does not do what it looks like it does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ufw default deny incoming
docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 9000:9000 some/admin-tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 9000 is open to anyone who can reach the host, ufw or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is a habit, not a firewall rule
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publish as little as possible.&lt;/strong&gt; On a well-laid-out Docker host only the reverse proxy publishes ports (80 and 443). Every other container is reached through the proxy on an internal Docker network and has no &lt;code&gt;ports:&lt;/code&gt; section at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind admin ports to localhost.&lt;/strong&gt; If a tool must publish a port for you alone, write &lt;code&gt;127.0.0.1:9000:9000&lt;/code&gt; and reach it through an SSH tunnel:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Reach a localhost-only admin port from your laptop&lt;/span&gt;
ssh &lt;span class="nt"&gt;-L&lt;/span&gt; 9000:127.0.0.1:9000 admin@192.168.20.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filter at the network edge.&lt;/strong&gt; Your router decides which VLANs can reach the Docker VM at all. Rules for traffic to containers can also go in Docker's &lt;code&gt;DOCKER-USER&lt;/code&gt; chain; the "Packet filtering and firewalls" page in Docker's docs covers it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A compose file that follows this looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;some/app:1.2&lt;/span&gt;
    &lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="c1"&gt;# no ports: the reverse proxy reaches it on the proxy network&lt;/span&gt;
  &lt;span class="na"&gt;admin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;some/admin-tool:3.1&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1:9000:9000"&lt;/span&gt;
&lt;span class="na"&gt;networks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;proxy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;external&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Set log rotation before the first real container
&lt;/h2&gt;

&lt;p&gt;By default Docker uses the json-file logging driver and performs no log rotation. A chatty container can fill the disk with logs. Docker's docs suggest either the &lt;code&gt;local&lt;/code&gt; driver or rotation options for json-file. Set them once in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"log-driver"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"json-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"log-opts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"max-size"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"max-file"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"3"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the quotes around &lt;code&gt;"3"&lt;/code&gt;: log options in daemon.json must be strings. Restart Docker to apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart docker
docker info &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.LoggingDriver}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common mistake: changing daemon.json and expecting old containers to pick it up. Logging options apply to containers created after the change. Recreate the running ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--force-recreate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One more thing about the docker group
&lt;/h2&gt;

&lt;p&gt;The post-install page warns that the docker group grants root-level privileges: anyone in it can start a container that mounts &lt;code&gt;/&lt;/code&gt; from the host. On a single-admin homelab VM that is usually acceptable. Never add a service account or a shared login to it, and anything that can talk to &lt;code&gt;/var/run/docker.sock&lt;/code&gt; controls the host, so mount the socket read-only where the tool allows it and only into tools you trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Only the reverse proxy publishes ports (80 and 443).&lt;/li&gt;
&lt;li&gt;Admin tools are bound to 127.0.0.1 or sit behind the proxy.&lt;/li&gt;
&lt;li&gt;VLAN rules on the router decide who can reach the Docker VM.&lt;/li&gt;
&lt;li&gt;Log rotation is set in &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt;, and existing containers were recreated.&lt;/li&gt;
&lt;li&gt;Nobody but you is in the docker group.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is adapted from my book &lt;em&gt;Containers Without the Chaos&lt;/em&gt; (Homelab the Right Way, Book 8). The free sample is &lt;a href="https://payhip.com/b/1qoiW" rel="noopener noreferrer"&gt;here&lt;/a&gt; and the full book is &lt;a href="https://payhip.com/b/Lmhcb" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI help and edited by the author; based on his book.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>docker</category>
      <category>selfhosted</category>
      <category>homelab</category>
      <category>security</category>
    </item>
    <item>
      <title>MediaPipe LLM Inference is maintenance-only: start on LiteRT-LM (and add this ABI check)</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Sat, 17 Oct 2026 14:01:22 +0000</pubDate>
      <link>https://dev.to/charleshartmann/mediapipe-llm-inference-is-maintenance-only-start-on-litert-lm-and-add-this-abi-check-48h2</link>
      <guid>https://dev.to/charleshartmann/mediapipe-llm-inference-is-maintenance-only-start-on-litert-lm-and-add-this-abi-check-48h2</guid>
      <description>&lt;p&gt;Running a language model on the phone itself is the most private option you have and the most demanding. The model file is large, it needs memory while it runs, and it turns battery into words. In return, the feature works in airplane mode on most recent 64-bit phones, and you choose the model.&lt;/p&gt;

&lt;h2&gt;
  
  
  LiteRT-LM replaces the MediaPipe LLM Inference API
&lt;/h2&gt;

&lt;p&gt;For the last couple of years the usual way to do this was MediaPipe's LLM Inference API. Google's MediaPipe documentation now carries a notice that the LLM Inference API (Android, iOS and Web) is in maintenance-only mode, that new features and optimizations are focused on LiteRT-LM, and that projects should migrate.&lt;/p&gt;

&lt;p&gt;New code should start on LiteRT-LM.&lt;/p&gt;

&lt;p&gt;LiteRT-LM is Google's runtime for language models on top of LiteRT. Its Kotlin API is small:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an &lt;code&gt;Engine&lt;/code&gt; loads a &lt;code&gt;.litertlm&lt;/code&gt; model&lt;/li&gt;
&lt;li&gt;a &lt;code&gt;Conversation&lt;/code&gt; holds the chat state&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sendMessageAsync&lt;/code&gt; streams the reply as a Kotlin &lt;code&gt;Flow&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The current release on Google Maven at the time of writing (October 2026) is 0.18.0, still below 1.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.google.ai.edge.litertlm:litertlm-android:0.18.0"&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;h2&gt;
  
  
  Getting a model
&lt;/h2&gt;

&lt;p&gt;LiteRT-LM reads &lt;code&gt;.litertlm&lt;/code&gt; files. Google points to the LiteRT Community organization on Hugging Face for ready-made ones, including Gemma builds.&lt;/p&gt;

&lt;p&gt;The author ships Android apps on Google Play as Hartmann Studios, and his ollama-android-client keeps a curated catalog in &lt;code&gt;LocalModelCatalog.kt&lt;/code&gt;. Its rule: prefer the smallest CPU-friendly quantization (int4 or q8) over f32 "so downloads stay reasonable on mobile data and the model actually fits in RAM". Two entries from that file:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Catalog entry&lt;/th&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Approx. size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 3 270M IT (q8)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma3-270m-it-q8.litertlm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;304 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 3 1B IT (int4)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gemma3-1b-it-int4.litertlm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;584 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the rest of this article, assume the file is in your app's private storage and you have its path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Engine and conversation
&lt;/h2&gt;

&lt;p&gt;The documentation is explicit that &lt;code&gt;engine.initialize()&lt;/code&gt; can take a significant amount of time (it gives "up to 10 seconds" as an example) and should run on a background thread or coroutine. Load the engine once and keep it. Create a new conversation per chat.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LocalLlm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;modelPath&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;lock&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mutex&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Engine&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;withLock&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;?:&lt;/span&gt; &lt;span class="nf"&gt;withContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nc"&gt;Engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;EngineConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;modelPath&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;modelPath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;backend&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CPU&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
                &lt;span class="n"&gt;cacheDir&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cacheDir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// speeds up later loads&lt;/span&gt;
            &lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;also&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;also&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;null&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;A conversation takes an optional system instruction, earlier messages and sampling settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConversationConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;systemInstruction&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Contents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"You are a concise, friendly assistant."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;initialMessages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;listOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nc"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"What is a homelab?"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"A few computers at home that run your own services."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;samplerConfig&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SamplerConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;topK&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="n"&gt;topP&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&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;h2&gt;
  
  
  Streaming a reply as a Flow
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sendMessageAsync(text)&lt;/code&gt; without a callback returns a &lt;code&gt;Flow&amp;lt;Message&amp;gt;&lt;/code&gt;, which the documentation recommends for coroutine users. In the book this sits behind a small &lt;code&gt;TextGenerator&lt;/code&gt; interface so the UI does not care which backend answers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;List&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ChatMessage&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;):&lt;/span&gt; &lt;span class="nc"&gt;Flow&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;flow&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;history&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dropLast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;System&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&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;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;User&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nc"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;system&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;firstOrNull&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;role&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="nc"&gt;Role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;System&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;config&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;ConversationConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;systemInstruction&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="o"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;let&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&gt;Contents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;of&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;initialMessages&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;createConversation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;conversation&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt;
        &lt;span class="n"&gt;conversation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessageAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;flowOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Dispatchers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Default&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;flowOn&lt;/code&gt;, not &lt;code&gt;withContext&lt;/code&gt;, inside a &lt;code&gt;flow { }&lt;/code&gt; builder. ollama-android-client's &lt;code&gt;LiteRtChatService.kt&lt;/code&gt; documents why in a comment: a &lt;code&gt;flow { }&lt;/code&gt; builder cannot emit from a different dispatcher than the one that collected it, and wrapping the body in &lt;code&gt;withContext&lt;/code&gt; throws "Flow invariant is violated" at runtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ABI check is not optional
&lt;/h2&gt;

&lt;p&gt;This is the part that comes from a real crash report.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;LiteRtChatService.kt&lt;/code&gt; notes that the litertlm-android AAR versions it uses only ship native libraries for arm64-v8a and x86_64, with no 32-bit ARM build. Phones without those ABIs threw &lt;code&gt;UnsatisfiedLinkError&lt;/code&gt; the moment the native library loaded.&lt;/p&gt;

&lt;p&gt;The fix has two parts. First, check &lt;code&gt;Build.SUPPORTED_ABIS&lt;/code&gt; up front:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;availability&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Availability&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="nc"&gt;Build&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SUPPORTED_ABIS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"arm64-v8a"&lt;/span&gt; &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="n"&gt;it&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"x86_64"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="nc"&gt;Availability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Ready&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nc"&gt;Availability&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Unavailable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"This phone's processor is not supported"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Second, catch &lt;code&gt;Throwable&lt;/code&gt; rather than &lt;code&gt;Exception&lt;/code&gt; around engine creation. &lt;code&gt;UnsatisfiedLinkError&lt;/code&gt; is an &lt;code&gt;Error&lt;/code&gt;, not an &lt;code&gt;Exception&lt;/code&gt;, and it slipped past the old catch.&lt;/p&gt;

&lt;h2&gt;
  
  
  GPU first, CPU as the safety net
&lt;/h2&gt;

&lt;p&gt;The GPU backend is usually faster for language models, but it does not work on every phone or with every model. To use it, the LiteRT-LM Android guide says to declare the OpenCL-related native libraries in the manifest, marked as not required:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;application&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;uses-native-library&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"libvndksupport.so"&lt;/span&gt; &lt;span class="na"&gt;android:required=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;uses-native-library&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"libOpenCL.so"&lt;/span&gt; &lt;span class="na"&gt;android:required=&lt;/span&gt;&lt;span class="s"&gt;"false"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/application&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then try GPU and fall back to CPU. ollama-android-client builds a list of backend attempts and, inside each, a list of history lengths to try: the full compacted history, then half, then one exchange, then none. Only when every rung fails does the user see an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fitting the conversation into the context window
&lt;/h2&gt;

&lt;p&gt;A language model on a phone has a small context window, and the conversation grows with every message. When the history no longer fits, generation fails. ollama-android-client trims history to a token budget before every request:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;It estimates tokens cheaply as characters divided by four, plus a small overhead per message.&lt;/li&gt;
&lt;li&gt;It allows history to use 55 percent of the model's token limit, leaving the rest for the new message and the reply.&lt;/li&gt;
&lt;li&gt;It keeps the newest exchanges that fit, then adds the very first exchange back if there is room, then up to a couple of older exchanges that share keywords with the new message.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The chat itself stays in Room. Only the copy sent to the model is trimmed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Releasing the model
&lt;/h2&gt;

&lt;p&gt;A loaded model holds a large amount of native memory. Close the conversation when a chat ends (the &lt;code&gt;use&lt;/code&gt; block above does this) and close the engine when the user leaves the AI part of the app or the system signals memory pressure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AssistantApp&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Application&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;lateinit&lt;/span&gt; &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="py"&gt;localLlm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;LocalLlm&lt;/span&gt;

    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;onTrimMemory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;super&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;onTrimMemory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;level&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;level&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nc"&gt;ComponentCallbacks2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TRIM_MEMORY_BACKGROUND&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;localLlm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;New code on LiteRT-LM, not the MediaPipe LLM Inference API.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;engine.initialize()&lt;/code&gt; off the main thread, once.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flowOn&lt;/code&gt;, never &lt;code&gt;withContext&lt;/code&gt;, inside &lt;code&gt;flow { }&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;Build.SUPPORTED_ABIS&lt;/code&gt; for arm64-v8a or x86_64 before loading.&lt;/li&gt;
&lt;li&gt;Catch &lt;code&gt;Throwable&lt;/code&gt; around engine creation.&lt;/li&gt;
&lt;li&gt;GPU first, CPU fallback, with the manifest entries marked not required.&lt;/li&gt;
&lt;li&gt;Release the engine on memory pressure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is adapted from my book &lt;em&gt;On-Device AI for Android&lt;/em&gt; (Android the Right Way, Book 5). The free sample is &lt;a href="https://payhip.com/b/Hz16R" rel="noopener noreferrer"&gt;here&lt;/a&gt; and the full book is &lt;a href="https://payhip.com/b/J9jlp" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Drafted with AI help and edited by the author; based on his book.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Ten quick edits should not queue ten syncs: unique work in WorkManager, done right</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Fri, 16 Oct 2026 17:47:13 +0000</pubDate>
      <link>https://dev.to/charleshartmann/ten-quick-edits-should-not-queue-ten-syncs-unique-work-in-workmanager-done-right-23b0</link>
      <guid>https://dev.to/charleshartmann/ten-quick-edits-should-not-queue-ten-syncs-unique-work-in-workmanager-done-right-23b0</guid>
      <description>&lt;p&gt;The user saves a note on the subway and puts the phone in their pocket. Android may kill your process a minute later. The note still has to reach the server when the train comes up for air, maybe in an hour, maybe tomorrow. That is persistent work, and on Android persistent work means WorkManager. Google's offline-first guide says as much: draining an offline queue is persistent work that is often delegated to WorkManager.&lt;/p&gt;

&lt;p&gt;WorkManager 2.12.0 is the stable release at the time of writing (October 2026). It stores every request in its own database, so scheduled work survives process death and reboots. The scheduling part is easy. The part that goes wrong is how many syncs you schedule, and what the worker says when one fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: one sync per save
&lt;/h2&gt;

&lt;p&gt;The obvious design is to enqueue a &lt;code&gt;SyncWorker&lt;/code&gt; from the repository every time a row is written. It works in the emulator. Then a user edits a note ten times in a minute, and ten syncs are queued. Each one pushes the same dirty rows, each one pulls the same pages, and two of them can run at the same time against the same database. Add a retry on a flaky connection and the queue grows faster than it drains.&lt;/p&gt;

&lt;p&gt;The fix is three decisions, all of them one line each: make the push unique work, pick the right policy for duplicates, and return &lt;code&gt;Result.retry()&lt;/code&gt; only for errors that can actually clear up on their own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: unique work with the right policy
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;enqueueUniqueWork&lt;/code&gt; (one-time) and &lt;code&gt;enqueueUniquePeriodicWork&lt;/code&gt; (periodic) take a name and a policy for what to do when unfinished work with that name already exists. For one-time work, &lt;code&gt;ExistingWorkPolicy&lt;/code&gt; has four options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;REPLACE&lt;/strong&gt; cancels the existing work and enqueues the new request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KEEP&lt;/strong&gt; ignores the new request and keeps the existing one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;APPEND&lt;/strong&gt; adds the new work to the end of the existing chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;APPEND_OR_REPLACE&lt;/strong&gt; appends unless the existing chain has failed or been cancelled, in which case it starts fresh.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a push, KEEP is the right one. If a sync is already queued, it will pick up every dirty row when it runs, including the one just saved, so the new request adds nothing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;object&lt;/span&gt; &lt;span class="nc"&gt;SyncScheduler&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;PUSH&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"notes-push"&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;PERIODIC&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"notes-periodic"&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;requestPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wifiOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OneTimeWorkRequestBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SyncWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;()&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;syncConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wifiOnly&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setBackoffCriteria&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BackoffPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;EXPONENTIAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nc"&gt;WorkManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueueUniqueWork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PUSH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ExistingWorkPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;KEEP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;schedulePeriodic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wifiOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;request&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PeriodicWorkRequestBuilder&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SyncWorker&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;TimeUnit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;HOURS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;syncConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wifiOnly&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nc"&gt;WorkManager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getInstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enqueueUniquePeriodicWork&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;PERIODIC&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ExistingPeriodicWorkPolicy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;KEEP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="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;Call &lt;code&gt;schedulePeriodic&lt;/code&gt; from &lt;code&gt;Application.onCreate()&lt;/code&gt;; with KEEP it is harmless to call on every launch. Periodic work has a floor: the minimum repeat interval is 15 minutes, the same as JobScheduler. A flex interval lets it run within a window at the end of each period rather than at an exact moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: constraints that match the user's settings
&lt;/h2&gt;

&lt;p&gt;Constraints make WorkManager wait for the right conditions. For sync, a connected network is usually enough, plus the user's Wi-Fi-only setting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;syncConstraints&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;wifiOnly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Constraints&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Builder&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRequiredNetworkType&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;wifiOnly&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;NetworkType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UNMETERED&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nc"&gt;NetworkType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CONNECTED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRequiresBatteryNotLow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One consequence is easy to miss: if a constraint stops being met while the worker runs, WorkManager stops the worker and retries it later. Your &lt;code&gt;doWork&lt;/code&gt; must be safe to stop at any suspension point, which is one more reason every sync step should be idempotent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: retry only what can succeed later
&lt;/h2&gt;

&lt;p&gt;When a worker returns &lt;code&gt;Result.retry()&lt;/code&gt;, WorkManager waits for the backoff delay and runs it again. &lt;code&gt;Result.failure()&lt;/code&gt; stops it, and fails everything chained after it. Deciding which errors are transient (timeouts, HTTP 5xx, 429) and which are permanent (401 after a token refresh, 400 for a malformed payload) is the most important line of sync code you will write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SyncWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;WorkerParameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="kd"&gt;val&lt;/span&gt; &lt;span class="py"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;SyncEngine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;CoroutineWorker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;override&lt;/span&gt; &lt;span class="k"&gt;suspend&lt;/span&gt; &lt;span class="k"&gt;fun&lt;/span&gt; &lt;span class="nf"&gt;doWork&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sync&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;syncOnce&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nc"&gt;SyncOutcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Done&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;success&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nc"&gt;SyncOutcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Transient&lt;/span&gt; &lt;span class="p"&gt;-&amp;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;runAttemptCount&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retry&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="nc"&gt;SyncOutcome&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Permanent&lt;/span&gt; &lt;span class="p"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;failure&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backoff numbers come from the WorkManager guide: the delay can be no less than 10 seconds, the policy is LINEAR or EXPONENTIAL, and the default is EXPONENTIAL with a 30-second delay. That is the exponential backoff the offline-first guide recommends for network reads and queued writes, for free: your code only classifies the failure and returns &lt;code&gt;retry()&lt;/code&gt;, with no sleep loop of its own. Cap attempts with &lt;code&gt;runAttemptCount&lt;/code&gt;, as above, so a permanently broken request does not retry forever.&lt;/p&gt;

&lt;p&gt;A worker with constructor parameters beyond context and params needs a &lt;code&gt;WorkerFactory&lt;/code&gt;. With Hilt, annotate it with &lt;code&gt;@HiltWorker&lt;/code&gt; and use &lt;code&gt;HiltWorkerFactory&lt;/code&gt;; without DI, register a custom factory through &lt;code&gt;Configuration.Provider&lt;/code&gt; in your &lt;code&gt;Application&lt;/code&gt; and disable the default initializer in the manifest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chains, expedited work and the foreground rules
&lt;/h2&gt;

&lt;p&gt;Work can run in sequence, each step's output becoming the next step's input. A chain is the natural shape for "upload attachments, then push notes, then pull":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;WorkManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getInstance&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;beginUniqueWork&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"full-sync"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;ExistingWorkPolicy&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;KEEP&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uploadAttachments&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pushNotes&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;then&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pullNotes&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;enqueue&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The chaining guide's failure rule: when a step fails or is cancelled, every dependent step is marked failed or cancelled too. Keep chains short and make each step meaningful on its own, so a failure tells you something.&lt;/p&gt;

&lt;p&gt;When the user is waiting (they tapped "Sync now"), mark the request expedited with &lt;code&gt;setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)&lt;/code&gt;, so an exhausted quota degrades to normal work instead of dropping it. On Android versions before 12, WorkManager may run expedited work as a foreground service, so any worker used with &lt;code&gt;setExpedited&lt;/code&gt; must implement &lt;code&gt;getForegroundInfo()&lt;/code&gt;; leaving it out can crash on older platform versions.&lt;/p&gt;

&lt;p&gt;For a long first sync, a &lt;code&gt;CoroutineWorker&lt;/code&gt; can call &lt;code&gt;setForeground()&lt;/code&gt; to run as a foreground service with an ongoing notification. If your app targets Android 14 (API 34) or higher, you must specify a foreground service type for every long-running worker, declare it in the manifest by merging it into WorkManager's own service, and request the matching permission (&lt;code&gt;FOREGROUND_SERVICE_DATA_SYNC&lt;/code&gt; for sync). Starting with Android 16, the guide notes, long-running workers can exhaust your app's job quota. For most apps the honest design is a sync that does a bounded amount of work per run and lets the next run continue, rather than one heroic foreground session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Show the state from the repository, not the worker
&lt;/h2&gt;

&lt;p&gt;To display "Syncing..." or "Last sync failed", observe the unique work's state as a Flow with &lt;code&gt;getWorkInfosForUniqueWorkFlow("notes-push")&lt;/code&gt; and map &lt;code&gt;RUNNING&lt;/code&gt;, &lt;code&gt;ENQUEUED&lt;/code&gt; and &lt;code&gt;FAILED&lt;/code&gt; to UI states. The worker itself never touches the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The push is unique work named once, enqueued with &lt;code&gt;ExistingWorkPolicy.KEEP&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Periodic sync is unique too, scheduled from &lt;code&gt;Application.onCreate()&lt;/code&gt;, interval 15 minutes or more.&lt;/li&gt;
&lt;li&gt;Constraints read the user's Wi-Fi-only setting; &lt;code&gt;doWork&lt;/code&gt; is safe to stop at any suspension point.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retry()&lt;/code&gt; only for timeouts, 5xx and 429; &lt;code&gt;failure()&lt;/code&gt; for 400 and 401 after a refresh.&lt;/li&gt;
&lt;li&gt;Attempts capped with &lt;code&gt;runAttemptCount&lt;/code&gt;; backoff left to WorkManager.&lt;/li&gt;
&lt;li&gt;Chains short; a failed step fails its dependents, and that is intended.&lt;/li&gt;
&lt;li&gt;Expedited workers implement &lt;code&gt;getForegroundInfo()&lt;/code&gt;; long-running workers declare a foreground service type when targeting API 34+.&lt;/li&gt;
&lt;li&gt;Every sync step is idempotent, because a stopped worker runs it again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A sync that outlives the app is not the clever part. The clever part is making sure only one of them runs, and that it gives up at the right moment.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/posts/data-that-survives-workmanager-unique-work-sync/" rel="noopener noreferrer"&gt;Homelab the Right Way blog&lt;/a&gt;. This article is based on the ebook &lt;a href="https://payhip.com/b/TaqWf" rel="noopener noreferrer"&gt;Data That Survives&lt;/a&gt;; every book in the series has a free sample. Written with AI help and edited by me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>kotlin</category>
      <category>workmanager</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Is Jellyfin really using your GPU? A six-rung ladder to prove it on Proxmox</title>
      <dc:creator>Charles Hartmann</dc:creator>
      <pubDate>Fri, 16 Oct 2026 17:46:47 +0000</pubDate>
      <link>https://dev.to/charleshartmann/is-jellyfin-really-using-your-gpu-a-six-rung-ladder-to-prove-it-on-proxmox-4ndo</link>
      <guid>https://dev.to/charleshartmann/is-jellyfin-really-using-your-gpu-a-six-rung-ladder-to-prove-it-on-proxmox-4ndo</guid>
      <description>&lt;p&gt;Most of the time a media server just sends the file. Sometimes the app on the other end cannot play it: a 4K HEVC film on an old TV, a high-bitrate file on a phone on mobile data, subtitles the app cannot draw itself. Then the server converts the video while you watch. That is transcoding, and on the CPU it is slow, hot and often not fast enough for real time. An Intel integrated GPU has dedicated video engines for exactly this, and the Jellyfin documentation recommends QSV (Intel Quick Sync Video) on mainstream Intel GPUs. Getting it to work inside a Proxmox container is a short list of steps. Knowing that it works is a separate job, and the one most guides skip.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: a dropdown that says QSV
&lt;/h2&gt;

&lt;p&gt;Set &lt;em&gt;Hardware acceleration&lt;/em&gt; to Intel QuickSync in the Jellyfin dashboard and the page looks done. It is not. If the container cannot open the render device, or the group number is wrong, or the source codec is not ticked for hardware decoding, Jellyfin quietly falls back to software. The film still plays. The CPU does the work. Nothing in the player tells you.&lt;/p&gt;

&lt;p&gt;The fix is a ladder: six checks, each ruling out one failure, climbed in order. If a rung fails, fix it before climbing further.&lt;/p&gt;

&lt;p&gt;Two things first. Intel processors whose model ends in "F" have no integrated GPU, and the Jellyfin docs say not to use them for this. And the old recipe of a privileged container with &lt;code&gt;lxc.cgroup2.devices.allow&lt;/code&gt; lines is kept in the Jellyfin docs only for Proxmox VE 7 and older. On Proxmox VE 8 and 9 the container stays unprivileged.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 1: the host has a render device
&lt;/h2&gt;

&lt;p&gt;On the Proxmox host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; /dev/dri
getent group render
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see &lt;code&gt;renderD128&lt;/code&gt; (and &lt;code&gt;card0&lt;/code&gt; or similar) owned by the &lt;code&gt;render&lt;/code&gt; or &lt;code&gt;video&lt;/code&gt; group. If &lt;code&gt;/dev/dri&lt;/code&gt; is missing, the host kernel has no driver for the GPU, and nothing below will work until it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 2: the device reaches the container with the right group
&lt;/h2&gt;

&lt;p&gt;Proxmox VE 8 and newer can pass a device into a container through the web interface, and the Jellyfin documentation recommends exactly this route. The detail that matters is the group id, and it is the container's group id, not the host's. Inside the container:&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;# inside the media container (CT 110)&lt;/span&gt;
getent group render
&lt;span class="c"&gt;# render:x:993:      &amp;lt;- your number may differ&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On some systems the group is &lt;code&gt;video&lt;/code&gt; or &lt;code&gt;input&lt;/code&gt; instead of &lt;code&gt;render&lt;/code&gt;; use whichever exists. Then on the host, in the container's Resources tab, choose Add &amp;gt; Device Passthrough, enter &lt;code&gt;/dev/dri/renderD128&lt;/code&gt; as the device path, and set the GID in the advanced options to the number you just found. The same thing from the command line, using the &lt;code&gt;dev[n]&lt;/code&gt; option from the Proxmox documentation:&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 the Proxmox host&lt;/span&gt;
pct &lt;span class="nb"&gt;set &lt;/span&gt;110 &lt;span class="nt"&gt;--dev0&lt;/span&gt; /dev/dri/renderD128,gid&lt;span class="o"&gt;=&lt;/span&gt;993
pct reboot 110
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the restart, &lt;code&gt;ls -l /dev/dri&lt;/code&gt; inside the container should show &lt;code&gt;renderD128&lt;/code&gt; owned by the render group. The container is still unprivileged and the host keeps using the GPU. One catch from the Jellyfin docs: you must be logged in to Proxmox as root to add device passthrough; if the option is unavailable, that is why.&lt;/p&gt;

&lt;p&gt;If Jellyfin runs in a VM instead, the whole GPU moves into it with PCI passthrough, and the host, and every other guest, lose it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 3: Docker hands the device to Jellyfin
&lt;/h2&gt;

&lt;p&gt;The official Jellyfin image already contains Intel's user-mode media drivers and the OpenCL runtime, so nothing needs installing inside it. Docker only has to pass the device through and add the group that owns it, with the number from rung 2:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# /opt/stacks/jellyfin/compose.yaml (GPU part)&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;jellyfin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jellyfin/jellyfin:12.2&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1000:1000&lt;/span&gt;
    &lt;span class="na"&gt;group_add&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;993"&lt;/span&gt;          &lt;span class="c1"&gt;# gid of render inside the LXC&lt;/span&gt;
    &lt;span class="na"&gt;devices&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/dev/dri/renderD128:/dev/dri/renderD128&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you have two GPUs, the second one is usually &lt;code&gt;renderD129&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 4: the drivers open the GPU from inside
&lt;/h2&gt;

&lt;p&gt;Before touching any Jellyfin setting, confirm the container can actually use the device. Both commands come from the Jellyfin documentation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; jellyfin /usr/lib/jellyfin-ffmpeg/vainfo
docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; jellyfin /usr/lib/jellyfin-ffmpeg/ffmpeg &lt;span class="nt"&gt;-v&lt;/span&gt; verbose &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-init_hw_device&lt;/span&gt; &lt;span class="nv"&gt;vaapi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;va &lt;span class="nt"&gt;-init_hw_device&lt;/span&gt; opencl@va
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;vainfo&lt;/code&gt; should name the Intel iHD driver and list the supported profiles and entry points. The second command should list an Intel OpenCL device. If you get &lt;em&gt;failed to initialize display&lt;/em&gt; or a permission error, the group in &lt;code&gt;group_add&lt;/code&gt; does not match the group that owns &lt;code&gt;/dev/dri/renderD128&lt;/code&gt; inside the container. Fix the number and recreate the container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rung 5: the dashboard settings match your GPU
&lt;/h2&gt;

&lt;p&gt;Now the transcoding settings in the Jellyfin dashboard:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hardware acceleration: Intel QuickSync (QSV), or VA-API if you chose that.&lt;/li&gt;
&lt;li&gt;Device: leave &lt;code&gt;/dev/dri/renderD128&lt;/code&gt; unless you use a second GPU.&lt;/li&gt;
&lt;li&gt;Hardware decoding: tick only the codecs your GPU supports, as the docs instruct. The &lt;code&gt;vainfo&lt;/code&gt; output and the codec tables in the Jellyfin Intel guide tell you which. Older iGPUs cannot decode HEVC 10-bit or AV1 in hardware; AV1 decoding starts with Gen 12 (11th Gen Core) graphics.&lt;/li&gt;
&lt;li&gt;Enable hardware encoding.&lt;/li&gt;
&lt;li&gt;Turn on tone mapping, so HDR sources look right on SDR screens. Both tone-mapping methods need a GPU with HEVC 10-bit decoding.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Rung 6: watch the GPU do the work
&lt;/h2&gt;

&lt;p&gt;A setting that says "QSV" proves nothing. Make Jellyfin transcode and watch the GPU.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Play a film in the web client and force a transcode by picking a lower quality in the player settings.&lt;/li&gt;
&lt;li&gt;In the dashboard, the active session should show that it is transcoding, not direct playing.&lt;/li&gt;
&lt;li&gt;On the machine that owns the GPU (the Proxmox host for a container, the VM for a VM), install and run &lt;code&gt;intel_gpu_top&lt;/code&gt;:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; intel-gpu-tools
intel_gpu_top
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Jellyfin guide explains the engine rows: &lt;em&gt;Video&lt;/em&gt; is the QSV decoder or encoder at work, &lt;em&gt;VideoEnhance&lt;/em&gt; is QSV VPP processing, &lt;em&gt;Render/3D&lt;/em&gt; covers OpenCL workloads such as tone mapping. During a hardware transcode the Video row should be busy. If every row sits at zero while the CPU climbs in &lt;code&gt;top&lt;/code&gt;, the transcode is running in software: go back down the ladder.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it still uses the CPU
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Subtitles burned in.&lt;/strong&gt; Image-based subtitles force a burn-in. It is still a video transcode, so make sure it is accelerated, and prefer text subtitles the client can render itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codec not ticked.&lt;/strong&gt; Jellyfin then decodes in software even when encoding on the GPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrong device after a reboot.&lt;/strong&gt; With more than one GPU, render node numbers can change. Pin the right one in both the passthrough and the compose file.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;CPU model does not end in "F"; Quick Sync confirmed on Intel's spec page.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/dev/dri/renderD128&lt;/code&gt; exists on the host.&lt;/li&gt;
&lt;li&gt;Render group gid read &lt;em&gt;inside&lt;/em&gt; the container; device passed with that gid; container still unprivileged.&lt;/li&gt;
&lt;li&gt;Jellyfin compose file has the device under &lt;code&gt;devices&lt;/code&gt; and the gid under &lt;code&gt;group_add&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;vainfo&lt;/code&gt; shows the iHD driver; the OpenCL check finds an Intel device.&lt;/li&gt;
&lt;li&gt;QSV selected with only supported codecs ticked; hardware encoding and tone mapping on.&lt;/li&gt;
&lt;li&gt;A forced transcode shows the Video engine busy in &lt;code&gt;intel_gpu_top&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;After a host kernel update, &lt;code&gt;vainfo&lt;/code&gt; and a forced transcode checked again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once this works, most of your playback should not need it at all: direct play, where the server just sends the file, is the goal, and transcoding is the safety net underneath it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://chartmann1590.github.io/homelab-the-right-way/posts/stream-your-own-library-prove-jellyfin-uses-the-gpu/" rel="noopener noreferrer"&gt;Homelab the Right Way blog&lt;/a&gt;. This article is based on the ebook &lt;a href="https://payhip.com/b/4uE86" rel="noopener noreferrer"&gt;Stream Your Own Library&lt;/a&gt;; every book in the series has a free sample. Written with AI help and edited by me.&lt;/em&gt;&lt;/p&gt;

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