<?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: Ian Packard</title>
    <description>The latest articles on DEV Community by Ian Packard (@ian_packard_2d172794449d1).</description>
    <link>https://dev.to/ian_packard_2d172794449d1</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3715707%2F20cdbb20-6e1a-48fc-a41f-6273c46339dd.png</url>
      <title>DEV Community: Ian Packard</title>
      <link>https://dev.to/ian_packard_2d172794449d1</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ian_packard_2d172794449d1"/>
    <language>en</language>
    <item>
      <title>Renaming WSL Distributions</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Thu, 19 Feb 2026 00:30:29 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/renaming-wsl-distributions-ikl</link>
      <guid>https://dev.to/octasoft-ltd/renaming-wsl-distributions-ikl</guid>
      <description>&lt;p&gt;You installed Ubuntu from the Microsoft Store and WSL named it &lt;code&gt;Ubuntu-24.04&lt;/code&gt;. You installed another one and got &lt;code&gt;Ubuntu&lt;/code&gt;. You imported one from a container and now you have three distributions with names that tell you nothing about what they're for.&lt;/p&gt;

&lt;p&gt;Wouldn't it be nice to rename them to something meaningful — &lt;code&gt;DevBox&lt;/code&gt;, &lt;code&gt;WebServer&lt;/code&gt;, &lt;code&gt;DataScience&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;The problem: WSL doesn't have a &lt;code&gt;--rename&lt;/code&gt; command. The distribution name is stored in the Windows Registry, and changing it means editing the registry directly. Worse, that name is also referenced by Windows Terminal profiles and Start Menu shortcuts, so renaming isn't just one change — it's several.&lt;/p&gt;

&lt;h2&gt;
  
  
  How WSL Stores Distribution Names
&lt;/h2&gt;

&lt;p&gt;Each WSL distribution is registered in the Windows Registry under:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside that path, each distribution has its own subkey identified by a GUID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Lxss\
  {12345678-abcd-...}\
    DistributionName = "Ubuntu-24.04"
    BasePath = "C:\Users\you\AppData\Local\wsl\..."
    State = 1
    Version = 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;DistributionName&lt;/code&gt; value is what &lt;code&gt;wsl --list&lt;/code&gt; shows you. It's also what you use in commands like &lt;code&gt;wsl -d Ubuntu-24.04&lt;/code&gt;. Changing this value is the core of a rename operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Manual Approach
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Find the Distribution's GUID
&lt;/h3&gt;

&lt;p&gt;Open PowerShell and find your distribution's registry key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-ChildItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;ForEach-Object&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="nv"&gt;$name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Get-ItemProperty&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PSPath&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;DistributionName&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;PSCustomObject&lt;/span&gt;&lt;span class="p"&gt;]@{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;GUID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PSChildName&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$name&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;This shows all your distributions and their GUIDs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GUID                                  Name
----                                  ----
{12345678-abcd-1234-abcd-123456789012} Ubuntu-24.04
{87654321-dcba-4321-dcba-210987654321} Alpine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Stop the Distribution
&lt;/h3&gt;

&lt;p&gt;The distribution must be stopped before renaming:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--terminate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu-24.04&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Edit the Registry
&lt;/h3&gt;

&lt;p&gt;Open Registry Editor (&lt;code&gt;regedit&lt;/code&gt;) and navigate to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Lxss\{your-guid}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Double-click &lt;code&gt;DistributionName&lt;/code&gt; and change it to your new name.&lt;/p&gt;

&lt;p&gt;Or from PowerShell (run as your normal user, not admin):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$guid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{12345678-abcd-1234-abcd-123456789012}"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Set-ItemProperty&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss\&lt;/span&gt;&lt;span class="nv"&gt;$guid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;`
&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;-Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DistributionName"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DevBox"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: Update Windows Terminal
&lt;/h3&gt;

&lt;p&gt;If you use Windows Terminal, it generates profile entries for each WSL distribution. After a registry rename, the Terminal profile still shows the old name.&lt;/p&gt;

&lt;p&gt;Windows Terminal stores WSL distribution profiles in a fragment JSON file. The location depends on how Terminal was installed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Microsoft Store Terminal:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminal_8wekyb3d8bbwe\LocalState\
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Preview Terminal:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;%LOCALAPPDATA%\Packages\Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe\LocalState\
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You need to update two things:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A. The profile fragment&lt;/strong&gt; (auto-generated by WSL):&lt;/p&gt;

&lt;p&gt;Find the profile matching your distribution's GUID and update the &lt;code&gt;name&lt;/code&gt; field.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;B. The settings.json file:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Open Terminal's settings.json and find your distribution in the &lt;code&gt;profiles.list&lt;/code&gt; array. Update the &lt;code&gt;name&lt;/code&gt; field to match:&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;"profiles"&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;"list"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"guid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{12345678-abcd-1234-abcd-123456789012}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DevBox"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Windows.Terminal.Wsl"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GUID in the Terminal profile matches the GUID in the registry, so you can find the right entry by comparing them (case-insensitive).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Rename the Start Menu Shortcut
&lt;/h3&gt;

&lt;p&gt;WSL distributions installed from the Store get a Start Menu shortcut. After renaming, the shortcut still has the old name.&lt;/p&gt;

&lt;p&gt;The shortcut is typically at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;%APPDATA%\Microsoft\Windows\Start Menu\Programs\
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Find the &lt;code&gt;.lnk&lt;/code&gt; file with the old name and rename it to match the new distribution name.&lt;/p&gt;

&lt;p&gt;After renaming the file, update the &lt;code&gt;ShortcutPath&lt;/code&gt; value in the registry if it exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HKCU\...\Lxss\{GUID}\ShortcutPath
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 6: Verify
&lt;/h3&gt;

&lt;p&gt;Start the distribution with its new name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;DevBox&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check Windows Terminal — the profile should now show "DevBox" in the dropdown.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjgs9tewgqhutir71tnwy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjgs9tewgqhutir71tnwy.png" alt="renaming-wsl-distributions/rename-workflow" width="800" height="1406"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming Rules
&lt;/h2&gt;

&lt;p&gt;WSL distribution names have restrictions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Allowed characters:&lt;/strong&gt; Letters, numbers, hyphens, underscores, periods&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Max length:&lt;/strong&gt; 64 characters&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cannot start with:&lt;/strong&gt; A hyphen&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Must be unique:&lt;/strong&gt; Case-insensitive (so "Ubuntu" and "ubuntu" conflict)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Choose names that are meaningful to you. Some patterns that work well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;By purpose:&lt;/strong&gt; &lt;code&gt;DevBox&lt;/code&gt;, &lt;code&gt;WebServer&lt;/code&gt;, &lt;code&gt;MLWorkspace&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By project:&lt;/strong&gt; &lt;code&gt;ProjectAlpha&lt;/code&gt;, &lt;code&gt;ClientSite&lt;/code&gt;, &lt;code&gt;Staging&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By distro + purpose:&lt;/strong&gt; &lt;code&gt;Ubuntu-Dev&lt;/code&gt;, &lt;code&gt;Debian-Build&lt;/code&gt;, &lt;code&gt;Alpine-Tools&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Easy Way: WSL UI
&lt;/h2&gt;

&lt;p&gt;The manual process is doable but tedious — five separate steps across the registry, Terminal, and file system. Miss one and things are inconsistent.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wsl-ui.octasoft.co.uk" rel="noopener noreferrer"&gt;WSL UI&lt;/a&gt; handles all of this with a single rename dialog. Enter the new name and optionally toggle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Update Windows Terminal profile&lt;/strong&gt; — updates both the profile fragment and settings.json (both Store and Preview variants)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rename Start Menu shortcut&lt;/strong&gt; — renames the .lnk file and updates the registry path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All updates happen atomically. If the Terminal or shortcut update fails (maybe Terminal isn't installed, or the shortcut doesn't exist), the rename still succeeds — those are non-fatal side effects.&lt;/p&gt;

&lt;p&gt;The validation is real-time too. As you type, it checks for invalid characters, duplicate names, and length limits before you can submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"wsl -d NewName" says distribution not found:&lt;/strong&gt;&lt;br&gt;
Make sure WSL isn't caching the old name. Run &lt;code&gt;wsl --shutdown&lt;/code&gt; then try again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Terminal still shows old name:&lt;/strong&gt;&lt;br&gt;
Check both the profile fragment JSON and the main settings.json. If you have both Terminal and Terminal Preview installed, both need updating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shortcut still shows old name in Start Menu:&lt;/strong&gt;&lt;br&gt;
The Start Menu may cache the old name. Try unpinning and repinning, or sign out and back in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Default distribution changed unexpectedly:&lt;/strong&gt;&lt;br&gt;
If your renamed distro was the default, verify with &lt;code&gt;wsl --list&lt;/code&gt; that it's still marked as default. If not, set it again: &lt;code&gt;wsl --set-default DevBox&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;What to Update&lt;/th&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Registry&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;DistributionName&lt;/code&gt; value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HKCU\...\Lxss\{GUID}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminal profile&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;name&lt;/code&gt; field&lt;/td&gt;
&lt;td&gt;Fragment JSON in Terminal LocalState&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terminal settings&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;name&lt;/code&gt; in profiles list&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;settings.json&lt;/code&gt; in Terminal LocalState&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Start Menu&lt;/td&gt;
&lt;td&gt;Shortcut filename&lt;/td&gt;
&lt;td&gt;&lt;code&gt;%APPDATA%\...\Start Menu\Programs\&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registry (shortcut)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ShortcutPath&lt;/code&gt; value&lt;/td&gt;
&lt;td&gt;&lt;code&gt;HKCU\...\Lxss\{GUID}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;WSL not having a built-in rename command is one of those gaps that's surprisingly annoying in practice. Giving your distributions meaningful names makes everything clearer — from &lt;code&gt;wsl -d DevBox&lt;/code&gt; in the terminal to the dropdown in Windows Terminal.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/renaming-wsl-distributions" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/renaming-wsl-distributions&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wsl</category>
      <category>windows</category>
      <category>registry</category>
      <category>wt</category>
    </item>
    <item>
      <title>Importing Container Images as WSL Distributions</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Thu, 19 Feb 2026 00:29:50 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/importing-container-images-as-wsl-distributions-4066</link>
      <guid>https://dev.to/octasoft-ltd/importing-container-images-as-wsl-distributions-4066</guid>
      <description>&lt;p&gt;Most people install WSL distributions from the Microsoft Store — Ubuntu, Debian, Alpine, the usual suspects. But WSL can run any Linux filesystem. And you know what has a huge catalog of Linux filesystems ready to download? Container registries.&lt;/p&gt;

&lt;p&gt;Every Docker image on Docker Hub, GitHub Container Registry, Quay.io, or any OCI-compatible registry is essentially a Linux root filesystem packaged in layers. You can pull any of them and turn them into a WSL distribution.&lt;/p&gt;

&lt;p&gt;Want Fedora? Arch Linux? Rocky Linux? A minimal BusyBox environment? A pre-configured development image your team maintains? They're all available.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Manual Way (With Docker or Podman)
&lt;/h2&gt;

&lt;p&gt;If you already have Docker or Podman installed, the process is straightforward:&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Pull the Image
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull archlinux:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Create a Container and Export It
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker create &lt;span class="nt"&gt;--name&lt;/span&gt; temp-arch archlinux:latest
docker &lt;span class="nb"&gt;export &lt;/span&gt;temp-arch &lt;span class="nt"&gt;-o&lt;/span&gt; C:&lt;span class="se"&gt;\T&lt;/span&gt;emp&lt;span class="se"&gt;\a&lt;/span&gt;rchlinux-rootfs.tar
docker &lt;span class="nb"&gt;rm &lt;/span&gt;temp-arch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a container from the image (without starting it), exports its filesystem to a tar file, then removes the container.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Import into WSL
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Arch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D:\WSL\Arch"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\Temp\archlinux-rootfs.tar&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4: First Boot Setup
&lt;/h3&gt;

&lt;p&gt;The imported distribution will log in as root. Set up a user account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Arch&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a user (varies by distro)&lt;/span&gt;
useradd &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-G&lt;/span&gt; wheel myuser
passwd myuser

&lt;span class="c"&gt;# Set default user in wsl.conf&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /etc/wsl.conf &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;'
[user]
default=myuser
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--terminate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Arch&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Arch&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same approach works with Podman — just replace &lt;code&gt;docker&lt;/code&gt; with &lt;code&gt;podman&lt;/code&gt; in all commands.&lt;/p&gt;

&lt;h2&gt;
  
  
  Without Docker: The OCI Approach
&lt;/h2&gt;

&lt;p&gt;You don't need Docker or Podman installed to pull container images. OCI (Open Container Initiative) images are just files hosted on HTTP APIs. You can download them directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  How Container Images Work
&lt;/h3&gt;

&lt;p&gt;A container image consists of:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A manifest&lt;/strong&gt; — metadata describing the image, including its layers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layers&lt;/strong&gt; — gzipped tar files, each containing filesystem changes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A config&lt;/strong&gt; — runtime settings (environment variables, entry point, etc.)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When you &lt;code&gt;docker pull alpine&lt;/code&gt;, Docker fetches the manifest to learn what layers exist, downloads each layer, and stacks them on top of each other. That's all there is to it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1qnq50o29i1j10i0xj5d.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1qnq50o29i1j10i0xj5d.png" alt="importing-container-images-as-wsl-distributions/oci-pull-flow" width="784" height="1804"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Multi-Architecture Images
&lt;/h3&gt;

&lt;p&gt;Most images on Docker Hub support multiple architectures (amd64, arm64, etc.). The manifest you first receive is actually a &lt;strong&gt;manifest list&lt;/strong&gt; — an index pointing to architecture-specific manifests. For WSL, you want the &lt;code&gt;linux/amd64&lt;/code&gt; variant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Layer Merging and Whiteouts
&lt;/h3&gt;

&lt;p&gt;Container images use a layered filesystem. Each layer can add, modify, or delete files from the layers below it. Deletions are handled through &lt;strong&gt;whiteout files&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;.wh.filename&lt;/code&gt; — deletes a specific file from lower layers&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.wh..wh..opq&lt;/code&gt; — marks a directory as opaque, hiding all contents from lower layers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When building a rootfs for WSL, you need to process these whiteouts correctly. Simply extracting all layers on top of each other doesn't work — you'd end up with stale files that should have been deleted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why Not Just Extract on Windows?
&lt;/h3&gt;

&lt;p&gt;There's a subtlety that trips people up: you can't extract Linux tar layers to a Windows filesystem and then import them. Linux symlinks, permissions, and special files don't survive the round-trip through NTFS. The layers need to be merged in tar format, keeping the Linux-native metadata intact, and then imported directly by WSL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Popular Images to Try
&lt;/h2&gt;

&lt;p&gt;Here are some container images that make useful WSL distributions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Image&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;archlinux:latest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Rolling release, AUR access, cutting-edge packages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fedora:latest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Latest Fedora release, good for Red Hat ecosystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;rockylinux:9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;RHEL-compatible, great for enterprise dev work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;clearlinux:latest&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Intel-optimized, performance-focused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;amazonlinux:2023&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Match your AWS Lambda/EC2 environment locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;oraclelinux:9&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Oracle database development and testing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Any image with a full Linux userspace works. Minimal images like &lt;code&gt;alpine&lt;/code&gt; work too, though they use musl libc which can cause compatibility issues with some software.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Images that DON'T work well as WSL distributions:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;scratch&lt;/code&gt; — empty image, nothing to run&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;busybox&lt;/code&gt; — too minimal, missing package manager&lt;/li&gt;
&lt;li&gt;Application images (like &lt;code&gt;nginx&lt;/code&gt;, &lt;code&gt;postgres&lt;/code&gt;) — these are designed to run a single process, not be interactive environments&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Easy Way: WSL UI
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://wsl-ui.octasoft.co.uk" rel="noopener noreferrer"&gt;WSL UI&lt;/a&gt; has a built-in OCI client that handles all of this without Docker, Podman, or any command-line work. The "New Distribution" dialog includes a Container tab where you can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Browse a catalog of pre-configured container images&lt;/li&gt;
&lt;li&gt;Enter any custom image reference (e.g., &lt;code&gt;ghcr.io/myorg/myimage:latest&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;WSL UI pulls the manifest, downloads layers, handles authentication (Bearer tokens for public registries), merges layers with proper whiteout handling, and imports the result&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The built-in OCI client works for public registries. For private registries that need authentication beyond anonymous Bearer tokens, WSL UI can also delegate to Docker or Podman if they're installed — giving you the best of both worlds.&lt;/p&gt;

&lt;p&gt;Progress is shown as each layer downloads, so you can see how far along the pull is for large images.&lt;/p&gt;

&lt;h2&gt;
  
  
  Registry Authentication
&lt;/h2&gt;

&lt;p&gt;Public images on Docker Hub, GHCR, Quay.io, and MCR (Microsoft Container Registry) use a challenge-based authentication flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Client requests the manifest&lt;/li&gt;
&lt;li&gt;Registry responds with &lt;code&gt;401 Unauthorized&lt;/code&gt; and a &lt;code&gt;WWW-Authenticate&lt;/code&gt; header&lt;/li&gt;
&lt;li&gt;Client requests a token from the auth service&lt;/li&gt;
&lt;li&gt;Client retries with the Bearer token&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This happens automatically for public images — no login required. Private images need credentials, which is where having Docker or Podman configured with &lt;code&gt;docker login&lt;/code&gt; or &lt;code&gt;podman login&lt;/code&gt; helps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Post-Import Setup
&lt;/h2&gt;

&lt;p&gt;Container images are designed for containers, not interactive use. After importing, you'll typically want to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a non-root user&lt;/strong&gt; — containers run as root by default&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Install an init system&lt;/strong&gt; — some distros need systemd configured in &lt;code&gt;/etc/wsl.conf&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set up a package manager&lt;/strong&gt; — most images have one, but verify it works&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure locale and timezone&lt;/strong&gt; — container images often have minimal locale support&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For Ubuntu and Debian-based images:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt update &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; &lt;span class="nb"&gt;sudo &lt;/span&gt;locales
locale-gen en_US.UTF-8
useradd &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/bash &lt;span class="nt"&gt;-G&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;myuser
passwd myuser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Fedora/RHEL-based images:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;passwd
useradd &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; /bin/bash myuser
passwd myuser
usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; wheel myuser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pull with Docker&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker pull image:tag&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export container&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker create --name tmp image &amp;amp;&amp;amp; docker export tmp -o rootfs.tar &amp;amp;&amp;amp; docker rm tmp&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import to WSL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --import Name "D:\path" rootfs.tar&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set default user&lt;/td&gt;
&lt;td&gt;Edit &lt;code&gt;/etc/wsl.conf&lt;/code&gt; → &lt;code&gt;[user]&lt;/code&gt; → &lt;code&gt;default=username&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Container registries are the largest library of Linux environments available. Combined with WSL's import command, you can run virtually any Linux distribution — not just the handful in the Microsoft Store.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/importing-container-images-as-wsl-distributions" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/importing-container-images-as-wsl-distributions&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wsl</category>
      <category>docker</category>
      <category>containers</category>
      <category>oci</category>
    </item>
    <item>
      <title>Cloning WSL Distributions</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Thu, 19 Feb 2026 00:29:08 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/cloning-wsl-distributions-2l6</link>
      <guid>https://dev.to/octasoft-ltd/cloning-wsl-distributions-2l6</guid>
      <description>&lt;p&gt;You've spent hours setting up your WSL distribution — installed the right packages, configured your shell, tuned your development environment. Now you need to experiment with something risky. Maybe a major version upgrade, a new tool that might break things, or a client project that needs its own isolated environment.&lt;/p&gt;

&lt;p&gt;You don't want to mess up your working setup. What you want is a clone.&lt;/p&gt;

&lt;p&gt;WSL doesn't have a &lt;code&gt;--clone&lt;/code&gt; command, but you can achieve the same result with export and import. The idea is simple: export your distribution to a tar file, then import it as a new distribution with a different name.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to Clone a Distribution
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 1: Export the Source
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\Temp\ubuntu-clone.tar&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a complete snapshot of the distribution's filesystem. Everything is included — installed packages, user accounts, configuration files, your home directory contents.&lt;/p&gt;

&lt;p&gt;For large distributions, this can take a few minutes and the tar file will be roughly the same size as your used disk space inside the distro.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Import as a New Distribution
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu-Dev&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D:\WSL\Ubuntu-Dev"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\Temp\ubuntu-clone.tar&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The arguments are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Ubuntu-Dev&lt;/code&gt; — the name for the clone&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;D:\WSL\Ubuntu-Dev&lt;/code&gt; — where to store the clone's virtual disk&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;C:\Temp\ubuntu-clone.tar&lt;/code&gt; — the tar file from step 1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WSL creates a new VHDX file at the specified location and extracts the tar contents into it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Fix the Default User
&lt;/h3&gt;

&lt;p&gt;When you import from a tar archive, WSL defaults to logging in as root. You need to restore your default user.&lt;/p&gt;

&lt;p&gt;Start the clone as root and edit &lt;code&gt;/etc/wsl.conf&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu-Dev&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check if [user] section already exists&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="se"&gt;\]&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /etc/wsl.conf 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/wsl.conf &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;'
[user]
default=yourusername
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart the distribution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--terminate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu-Dev&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu-Dev&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;whoami&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This should show your username.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Clean Up
&lt;/h3&gt;

&lt;p&gt;Delete the temporary tar file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Remove-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\Temp\ubuntu-clone.tar&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Faster Cloning with VHD Format
&lt;/h2&gt;

&lt;p&gt;The tar export/import works but it's slow for large distributions. The entire filesystem gets serialized to tar, then extracted back. For a 50GB distribution, that's a lot of I/O.&lt;/p&gt;

&lt;p&gt;A faster approach uses VHD format, which copies the virtual disk directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Export as VHD (much faster)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\Temp\ubuntu-clone.vhdx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;vhd&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Import the VHD copy&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu-Dev&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D:\WSL\Ubuntu-Dev"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\Temp\ubuntu-clone.vhdx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--vhd&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The VHD method skips the tar serialization entirely. It copies the VHDX file as-is, which is significantly faster and also preserves your default user settings (no need to fix &lt;code&gt;/etc/wsl.conf&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Use Cases for Cloning
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Testing upgrades:&lt;/strong&gt; Clone before running &lt;code&gt;do-release-upgrade&lt;/code&gt; on Ubuntu. If it breaks, delete the clone and try again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project isolation:&lt;/strong&gt; Give each major project its own distribution with exactly the dependencies it needs. No conflicts between projects with different Node.js, Python, or library versions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experimentation:&lt;/strong&gt; Want to try a new shell, desktop environment, or system configuration? Clone first, experiment freely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Training environments:&lt;/strong&gt; Create a pre-configured development environment, clone it for each team member or workshop participant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before risky changes:&lt;/strong&gt; About to restructure your home directory, change your shell, or modify system services? A clone is your safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Easy Way: WSL UI
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://wsl-ui.octasoft.co.uk" rel="noopener noreferrer"&gt;WSL UI&lt;/a&gt; has a one-click clone feature. Right-click a distribution and choose "Clone" to get a dialog where you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enter a name for the clone (defaults to &lt;code&gt;DistroName-clone&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Optionally choose a custom install location&lt;/li&gt;
&lt;li&gt;Click Clone and wait&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Behind the scenes, it exports to a temporary file in &lt;code&gt;%TEMP%&lt;/code&gt;, imports with the new name, and cleans up automatically. It also tracks clone lineage in metadata — so you can see which distribution was cloned from which, useful when you have multiple clones and need to remember the original.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Choose meaningful names&lt;/strong&gt; for clones. &lt;code&gt;Ubuntu-Dev&lt;/code&gt;, &lt;code&gt;Ubuntu-Staging&lt;/code&gt;, &lt;code&gt;Ubuntu-ML&lt;/code&gt; are better than &lt;code&gt;Ubuntu-clone-2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put clones on a different drive&lt;/strong&gt; if your C: drive is running low. Use the install location parameter to direct the VHDX to D: or another drive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete clones when done.&lt;/strong&gt; Each clone has its own VHDX file that takes real disk space. Clean up with &lt;code&gt;wsl --unregister CloneName&lt;/code&gt; when you no longer need it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer VHD format&lt;/strong&gt; for large distributions. It's faster and preserves user settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Export (TAR)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --export Ubuntu backup.tar&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export (VHD, faster)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --export Ubuntu backup.vhdx --format vhd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import clone (TAR)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --import NewName "D:\path" backup.tar&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import clone (VHD)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --import NewName "D:\path" backup.vhdx --vhd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix default user&lt;/td&gt;
&lt;td&gt;Edit &lt;code&gt;/etc/wsl.conf&lt;/code&gt; → &lt;code&gt;[user]&lt;/code&gt; → &lt;code&gt;default=username&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete clone&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --unregister NewName&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cloning is one of those capabilities that changes how you work with WSL. Instead of being precious about your single distribution, you can branch off, experiment, and throw things away. Treat distributions like git branches — cheap to create, easy to discard.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/cloning-wsl-distributions" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/cloning-wsl-distributions&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wsl</category>
      <category>windows</category>
      <category>development</category>
      <category>testing</category>
    </item>
    <item>
      <title>Backing Up and Restoring WSL Distributions</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Thu, 19 Feb 2026 00:28:18 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/backing-up-and-restoring-wsl-distributions-3mi4</link>
      <guid>https://dev.to/octasoft-ltd/backing-up-and-restoring-wsl-distributions-3mi4</guid>
      <description>&lt;p&gt;Your WSL distribution is more than just an operating system. It's your development environment — your shell configuration, installed tools, SSH keys, project dependencies, database setups. Rebuilding all of that from scratch takes hours, sometimes days.&lt;/p&gt;

&lt;p&gt;And yet most people don't back up their WSL distributions. The VHDX file sitting in &lt;code&gt;%LOCALAPPDATA%&lt;/code&gt; is silently holding everything, and one bad Windows update, a disk failure, or an accidental &lt;code&gt;wsl --unregister&lt;/code&gt; could wipe it out.&lt;/p&gt;

&lt;p&gt;The fix is straightforward: WSL has built-in export and import commands that create portable backup archives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exporting a Distribution
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;wsl --export&lt;/code&gt; command creates a complete snapshot of your distribution's filesystem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu-2026-02-21.tar&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This produces an uncompressed tar archive containing every file in the distribution — installed packages, user accounts, configuration, home directories, everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Export Formats
&lt;/h3&gt;

&lt;p&gt;WSL supports several export formats:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Uncompressed TAR (default):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu.tar&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fastest to create and restore, but largest file size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compressed TAR (gzip):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu.tar.gz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tar.gz&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Significantly smaller file, takes longer to create and restore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compressed TAR (xz):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu.tar.xz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tar.xz&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Smallest file size, but slowest compression. Good for archival or transferring over the network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;VHD format:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu.vhdx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;vhd&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copies the virtual disk directly. Fastest export, preserves everything including disk structure and default user settings. Restore is also fast since it skips tar extraction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which Format Should I Use?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Speed&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Preserves User&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TAR&lt;/td&gt;
&lt;td&gt;Fast&lt;/td&gt;
&lt;td&gt;Large&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Quick local backups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TAR.GZ&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;External drives, sharing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TAR.XZ&lt;/td&gt;
&lt;td&gt;Slow&lt;/td&gt;
&lt;td&gt;Small&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Archival, network transfer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VHD&lt;/td&gt;
&lt;td&gt;Fastest&lt;/td&gt;
&lt;td&gt;Exact copy&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Fast backup/restore&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For regular backups to a local or external drive, &lt;strong&gt;VHD format&lt;/strong&gt; is usually the best choice. It's fast in both directions and preserves your default user setting. Use compressed TAR when file size matters more than speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Restoring from a Backup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  From a TAR Archive
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\WSL\Ubuntu"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu-2026-02-21.tar&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The arguments are: name, install location, and backup file path.&lt;/p&gt;

&lt;p&gt;After importing from TAR, you'll need to fix the default user since TAR imports default to root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Add your default user to wsl.conf if not already there&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"^&lt;/span&gt;&lt;span class="se"&gt;\[&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="se"&gt;\]&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; /etc/wsl.conf 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/wsl.conf &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;'
[user]
default=yourusername
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--terminate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;whoami&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  From a VHD Backup
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\WSL\Ubuntu"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu.vhdx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--vhd&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;VHD restores are faster and your default user setting is already correct — no manual fix needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Restoring Over an Existing Distribution
&lt;/h3&gt;

&lt;p&gt;If you're restoring to replace a damaged distribution, unregister the old one first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--unregister&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\WSL\Ubuntu"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu.vhdx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--vhd&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; &lt;code&gt;--unregister&lt;/code&gt; permanently deletes the distribution's virtual disk. Make sure your backup is valid before doing this.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Simple Backup Script
&lt;/h2&gt;

&lt;p&gt;Here's a PowerShell script that backs up all your distributions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$backupDir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D:\Backups\WSL"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$date&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Get-Date&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yyyy-MM-dd"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Create backup directory&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;New-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ItemType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Directory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$backupDir&lt;/span&gt;&lt;span class="s2"&gt;\&lt;/span&gt;&lt;span class="nv"&gt;$date&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Out-Null&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Get all distributions&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$distros&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--list&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--quiet&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Where-Object&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="bp"&gt;$_&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-and&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Trim&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="kr"&gt;foreach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$distro&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kr"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$distros&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="nv"&gt;$distro&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$distro&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nv"&gt;$backupFile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$backupDir&lt;/span&gt;&lt;span class="s2"&gt;\&lt;/span&gt;&lt;span class="nv"&gt;$date&lt;/span&gt;&lt;span class="s2"&gt;\&lt;/span&gt;&lt;span class="nv"&gt;$distro&lt;/span&gt;&lt;span class="s2"&gt;.vhdx"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Backing up &lt;/span&gt;&lt;span class="nv"&gt;$distro&lt;/span&gt;&lt;span class="s2"&gt;..."&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$distro&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$backupFile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;vhd&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"  Done: &lt;/span&gt;&lt;span class="nv"&gt;$backupFile&lt;/span&gt;&lt;span class="s2"&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="n"&gt;Write-Host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;`n&lt;/span&gt;&lt;span class="s2"&gt;All distributions backed up to &lt;/span&gt;&lt;span class="nv"&gt;$backupDir&lt;/span&gt;&lt;span class="s2"&gt;\&lt;/span&gt;&lt;span class="nv"&gt;$date&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save this as &lt;code&gt;backup-wsl.ps1&lt;/code&gt; and run it periodically. You could schedule it with Task Scheduler for automated weekly backups.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Included (and What's Not)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Included in the backup:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All installed packages and their configuration&lt;/li&gt;
&lt;li&gt;All user accounts and home directories&lt;/li&gt;
&lt;li&gt;System configuration (&lt;code&gt;/etc/&lt;/code&gt;, services, cron jobs)&lt;/li&gt;
&lt;li&gt;Custom scripts and tools you've installed&lt;/li&gt;
&lt;li&gt;Database data files (if running databases inside WSL)&lt;/li&gt;
&lt;li&gt;SSH keys, GPG keys, shell configuration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;NOT included:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WSL configuration from &lt;code&gt;.wslconfig&lt;/code&gt; (this is a Windows-side file at &lt;code&gt;%USERPROFILE%\.wslconfig&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Distribution-specific settings in the Windows Registry (default user, WSL version)&lt;/li&gt;
&lt;li&gt;Windows Terminal profile customizations&lt;/li&gt;
&lt;li&gt;Mounted Windows drives content (they're just mount points)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have customized &lt;code&gt;.wslconfig&lt;/code&gt;, back that up separately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Copy-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;USERPROFILE&lt;/span&gt;&lt;span class="s2"&gt;\.wslconfig"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D:\Backups\WSL\wslconfig-backup"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Easy Way: WSL UI
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://wsl-ui.octasoft.co.uk" rel="noopener noreferrer"&gt;WSL UI&lt;/a&gt; provides one-click export and import through the quick actions menu. Click Export on any distribution to get a save dialog with your distribution name and today's date as the default filename. Import opens a dialog where you select the tar file, enter a name, and choose an install location.&lt;/p&gt;

&lt;p&gt;The app also tracks import/export metadata — recording when and from where each distribution was imported, so you can trace the history of your environments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tips
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Include the date in filenames:&lt;/strong&gt; &lt;code&gt;Ubuntu-2026-02-21.tar&lt;/code&gt; makes it clear when the backup was taken&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store backups on a different drive:&lt;/strong&gt; If your C: drive fails, backups on D: or an external drive survive&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test your backups periodically:&lt;/strong&gt; Import a backup under a temporary name to verify it works, then &lt;code&gt;wsl --unregister&lt;/code&gt; the test import&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Back up before risky operations:&lt;/strong&gt; Before major upgrades, kernel changes, or experimental tooling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep at least two generations:&lt;/strong&gt; Don't overwrite your only backup — rotate between two or three copies&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Export (TAR)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --export &amp;lt;distro&amp;gt; backup.tar&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export (compressed)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --export &amp;lt;distro&amp;gt; backup.tar.gz --format tar.gz&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export (VHD, fastest)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --export &amp;lt;distro&amp;gt; backup.vhdx --format vhd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import (TAR)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --import &amp;lt;name&amp;gt; "&amp;lt;location&amp;gt;" backup.tar&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import (VHD)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --import &amp;lt;name&amp;gt; "&amp;lt;location&amp;gt;" backup.vhdx --vhd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix default user&lt;/td&gt;
&lt;td&gt;Edit &lt;code&gt;/etc/wsl.conf&lt;/code&gt; → &lt;code&gt;[user]&lt;/code&gt; → &lt;code&gt;default=username&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unregister old&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --unregister &amp;lt;distro&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Back up .wslconfig&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Copy-Item $env:USERPROFILE\.wslconfig &amp;lt;backup-path&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your WSL environment is worth protecting. A few minutes of backup now can save hours of rebuilding later.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/backing-up-and-restoring-wsl-distributions" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/backing-up-and-restoring-wsl-distributions&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wsl</category>
      <category>windows</category>
      <category>backup</category>
      <category>disasterrecovery</category>
    </item>
    <item>
      <title>Moving WSL Distributions to Another Drive</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Wed, 18 Feb 2026 23:13:30 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/moving-wsl-distributions-to-another-drive-3id4</link>
      <guid>https://dev.to/octasoft-ltd/moving-wsl-distributions-to-another-drive-3id4</guid>
      <description>&lt;p&gt;WSL2 distributions default to your C: drive. That's fine when you have one small Ubuntu install. It's less fine when you have five distros, each with a 20-50GB virtual disk, and your system drive is running out of space.&lt;/p&gt;

&lt;p&gt;The good news: you can move distributions to any drive. The less good news: there are a couple of ways to do it, and the right approach depends on your WSL version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where Are My Distributions Stored?
&lt;/h2&gt;

&lt;p&gt;Before moving anything, let's find where your distros currently live. WSL stores each distribution's virtual hard disk (VHDX) in a base path registered in the Windows Registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss\{GUID}\BasePath
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Common default locations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Newer WSL:&lt;/strong&gt; &lt;code&gt;%LOCALAPPDATA%\wsl\{GUID}\&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store-installed distros:&lt;/strong&gt; &lt;code&gt;%LOCALAPPDATA%\Packages\&amp;lt;distro-package&amp;gt;\LocalState\&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can check your distro sizes from PowerShell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-ChildItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;LOCALAPPDATA&lt;/span&gt;&lt;span class="s2"&gt;\wsl"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Recurse&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Filter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ext4.vhdx"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;FullName&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="nx"&gt;N&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'SizeGB'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{[&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Length&lt;/span&gt;&lt;span class="n"&gt;/1GB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2&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;Or list all your distributions and their WSL versions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--list&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--verbose&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Method 1: wsl --manage --move (Recommended)
&lt;/h2&gt;

&lt;p&gt;The simplest way to move a distribution is WSL's built-in move command. This relocates the VHDX file and updates the registry in one step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Stop the Distribution
&lt;/h3&gt;

&lt;p&gt;The distro must be stopped before moving:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--terminate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify it's stopped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--list&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--verbose&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look for "Stopped" next to your distro name.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Move It
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--manage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--move&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D:\WSL\Ubuntu"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WSL will create the destination directory if needed and move the VHDX file. This can take a while for large distros — a 50GB VHDX will take a few minutes depending on your disk speed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Verify
&lt;/h3&gt;

&lt;p&gt;Start the distro and confirm everything works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;whoami
ls&lt;/span&gt; ~
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Your distribution is now on D: and WSL knows where to find it. All your files, installed packages, and configuration are preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Method 2: Export and Import
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;--manage --move&lt;/code&gt; isn't available on your WSL version, or if you want to rename the distro at the same time, the export/import approach works on all WSL2 installations.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fowlz6meb5xbt2qr1zd16.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fowlz6meb5xbt2qr1zd16.png" alt="moving-wsl-distributions-to-another-drive/export-import-workflow" width="716" height="1442"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Export the Distribution
&lt;/h3&gt;

&lt;p&gt;You have two format options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TAR format (default, universal):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu-backup.tar&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;VHD format (faster, preserves disk structure):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu-backup.vhdx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;vhd&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The VHD format is significantly faster because it copies the VHDX file directly instead of creating a tar archive from the filesystem. For large distros, this can save considerable time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Compressed TAR (smaller file):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--export&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu-backup.tar.gz&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tar.gz&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Unregister the Old Distribution
&lt;/h3&gt;

&lt;p&gt;This removes the distribution from WSL's registry and deletes the original VHDX:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--unregister&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; This deletes the original distribution data. Make sure your export completed successfully before running this. Check the file size of your export — it should be reasonable (not 0 bytes).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Import to the New Location
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;From a TAR export:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D:\WSL\Ubuntu"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu-backup.tar&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;From a VHD export:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--import&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D:\WSL\Ubuntu"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu-backup.vhdx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--vhd&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The arguments are: &lt;code&gt;wsl --import &amp;lt;name&amp;gt; &amp;lt;install-location&amp;gt; &amp;lt;file-path&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Fix the Default User
&lt;/h3&gt;

&lt;p&gt;When you import from a TAR archive, WSL defaults to logging in as root. You need to restore your default user. Edit &lt;code&gt;/etc/wsl.conf&lt;/code&gt; inside the distro:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; /etc/wsl.conf &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;'
[user]
default=yourusername
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restart the distro for the change to take effect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--terminate&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;whoami&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This should now show your username instead of root.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you exported as VHD format, the &lt;code&gt;/etc/wsl.conf&lt;/code&gt; file is preserved as-is inside the VHDX, so your default user setting should already be correct.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Clean Up
&lt;/h3&gt;

&lt;p&gt;Once everything is working, delete the backup file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Remove-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;D:\Backups\ubuntu-backup.tar&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Method 3: Import In-Place (Advanced)
&lt;/h2&gt;

&lt;p&gt;If you've already manually copied a VHDX file to a new location, you can register it directly without WSL copying it again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--import-in-place&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D:\WSL\Ubuntu\ext4.vhdx"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This tells WSL to use the VHDX at its current location. The file must be ext4-formatted (which all WSL2 VHDX files are). This is the fastest option if you've already moved the file yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing the Right Method
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Factor&lt;/th&gt;
&lt;th&gt;--manage --move&lt;/th&gt;
&lt;th&gt;Export/Import (TAR)&lt;/th&gt;
&lt;th&gt;Export/Import (VHD)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;Fast (direct move)&lt;/td&gt;
&lt;td&gt;Slow (tar + extract)&lt;/td&gt;
&lt;td&gt;Medium (file copy)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disk space needed&lt;/td&gt;
&lt;td&gt;Just the destination&lt;/td&gt;
&lt;td&gt;2x (export + import)&lt;/td&gt;
&lt;td&gt;2x (export + import)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preserves user settings&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No (need to fix)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rename distro&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WSL version required&lt;/td&gt;
&lt;td&gt;Recent WSL&lt;/td&gt;
&lt;td&gt;Any WSL2&lt;/td&gt;
&lt;td&gt;Any WSL2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complexity&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most people, &lt;code&gt;--manage --move&lt;/code&gt; is the right choice. Use export/import when you need to rename the distro or are on an older WSL version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing New Distros on a Different Drive
&lt;/h2&gt;

&lt;p&gt;Prevention is better than cure. When installing new distributions, you can specify the location upfront:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--install&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--location&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"D:\WSL\Ubuntu"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This puts the VHDX on D: from the start, avoiding the need to move it later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Easy Way: WSL UI
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://wsl-ui.octasoft.co.uk" rel="noopener noreferrer"&gt;WSL UI&lt;/a&gt; makes moving distributions a point-and-click operation. The Move Distribution dialog:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Shows the current location and disk size&lt;/li&gt;
&lt;li&gt;Lets you browse to a new location&lt;/li&gt;
&lt;li&gt;Handles stopping the distro, moving the files, and updating the registry&lt;/li&gt;
&lt;li&gt;Shows progress for large moves&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No command line, no worrying about which method to use, no manual registry updates. It uses WSL's native move command under the hood, so everything is preserved — your files, settings, default user, and configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;"The operation is not supported" when using --manage --move:&lt;/strong&gt;&lt;br&gt;
You may be on an older WSL version. Update with &lt;code&gt;wsl --update&lt;/code&gt; or use the export/import method instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Import defaults to root user:&lt;/strong&gt;&lt;br&gt;
This is expected with TAR imports. Edit &lt;code&gt;/etc/wsl.conf&lt;/code&gt; as described in Step 4 of Method 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Access is denied" during move:&lt;/strong&gt;&lt;br&gt;
Make sure no WSL processes are using the distro. Run &lt;code&gt;wsl --shutdown&lt;/code&gt; to stop everything, then try again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distro not showing after import:&lt;/strong&gt;&lt;br&gt;
Check that the import path exists and you have write permissions. Try running PowerShell as Administrator.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Running out of space during export:&lt;/strong&gt;&lt;br&gt;
Export to the destination drive directly. For example, if moving from C: to D:, export to &lt;code&gt;D:\Backups\&lt;/code&gt; instead of a location on C:.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;List distros&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --list --verbose&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stop a distro&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --terminate &amp;lt;distro&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Move (simple)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --manage &amp;lt;distro&amp;gt; --move "D:\WSL\path"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export (TAR)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --export &amp;lt;distro&amp;gt; backup.tar&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Export (VHD)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --export &amp;lt;distro&amp;gt; backup.vhdx --format vhd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unregister&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --unregister &amp;lt;distro&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import (TAR)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --import &amp;lt;name&amp;gt; "D:\path" backup.tar&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import (VHD)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --import &amp;lt;name&amp;gt; "D:\path" backup.vhdx --vhd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import in-place&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --import-in-place &amp;lt;name&amp;gt; "D:\path\ext4.vhdx"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install on D:&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --install Ubuntu --location "D:\WSL\Ubuntu"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fix default user&lt;/td&gt;
&lt;td&gt;Edit &lt;code&gt;/etc/wsl.conf&lt;/code&gt; → &lt;code&gt;[user]&lt;/code&gt; → &lt;code&gt;default=username&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Moving a distro is one of those tasks you only do once or twice, but it makes a real difference when your C: drive is filling up.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/moving-wsl-distributions-to-another-drive" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/moving-wsl-distributions-to-another-drive&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wsl</category>
      <category>windows</category>
      <category>diskmanagement</category>
      <category>migration</category>
    </item>
    <item>
      <title>Managing WSL Disk Space</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Wed, 18 Feb 2026 23:12:52 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/managing-wsl-disk-space-fm9</link>
      <guid>https://dev.to/octasoft-ltd/managing-wsl-disk-space-fm9</guid>
      <description>&lt;p&gt;If you've been using WSL2 for a while, you've probably noticed your C: drive slowly losing space. Maybe 10GB here, 50GB there. The culprit? WSL2's virtual hard disk files — they grow as you use them but never shrink back on their own.&lt;/p&gt;

&lt;p&gt;This is one of those things that catches people off guard. You delete 20GB of files inside your distro, check your Windows disk space, and... nothing changed. The space is still gone.&lt;/p&gt;

&lt;p&gt;Let's fix that.&lt;/p&gt;

&lt;h2&gt;
  
  
  How WSL2 Stores Data
&lt;/h2&gt;

&lt;p&gt;WSL2 runs each distribution inside a lightweight virtual machine. Your Linux filesystem lives in a VHDX file — a Hyper-V virtual hard disk. When you install packages, download files, or build projects inside WSL, this VHDX file grows to accommodate the data.&lt;/p&gt;

&lt;p&gt;The problem: when you delete those files, the VHDX doesn't shrink. The file on your Windows drive stays the same size, even though the Linux filesystem inside it now has free space. Over months of use, this gap between actual usage and file size can become substantial.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding Your VHDX Files
&lt;/h2&gt;

&lt;p&gt;First, let's see what we're dealing with. Your VHDX files are typically stored in one of these locations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Default (newer WSL):&lt;/strong&gt; &lt;code&gt;%LOCALAPPDATA%\wsl\&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store-installed distros:&lt;/strong&gt; &lt;code&gt;%LOCALAPPDATA%\Packages\&amp;lt;distro-package&amp;gt;\LocalState\&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom locations:&lt;/strong&gt; Wherever you specified during &lt;code&gt;wsl --import&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To find the exact path for a specific distro, check the Windows Registry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss\{GUID}\BasePath
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The VHDX file is always called &lt;code&gt;ext4.vhdx&lt;/code&gt; inside that base path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Checking the Damage
&lt;/h3&gt;

&lt;p&gt;From PowerShell, you can see all your VHDX sizes at once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Get-ChildItem&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;LOCALAPPDATA&lt;/span&gt;&lt;span class="s2"&gt;\wsl"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Recurse&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Filter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ext4.vhdx"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;FullName&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="nx"&gt;N&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'SizeGB'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{[&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Length&lt;/span&gt;&lt;span class="n"&gt;/1GB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2&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;Inside each distro, check actual disk usage with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare those numbers. If your VHDX is 45GB but &lt;code&gt;df&lt;/code&gt; shows only 20GB used, you have 25GB of reclaimable space.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reclaiming Disk Space
&lt;/h2&gt;

&lt;p&gt;Compacting a VHDX is a multi-step process. You need to zero out the free space inside the Linux filesystem, shut down WSL so the file isn't locked, then compact the VHDX from Windows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl1zkqzyqimwozoipmv3k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fl1zkqzyqimwozoipmv3k.png" alt="managing-wsl-disk-space/compact-workflow" width="688" height="1466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Zero Unused Blocks with fstrim
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;fstrim&lt;/code&gt; command tells the filesystem to discard blocks that are no longer in use. This zeros them out so the VHDX compactor can identify and reclaim the space.&lt;/p&gt;

&lt;p&gt;Run this inside your distro (as root):&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;fstrim &lt;span class="nt"&gt;-av&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You should see output like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/: 12.5 GiB (13421772800 bytes) trimmed on /dev/sdd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That number tells you how much space is potentially reclaimable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Alpine Linux note:&lt;/strong&gt; Alpine uses BusyBox which has a simpler &lt;code&gt;fstrim&lt;/code&gt;. Use &lt;code&gt;sudo fstrim -v /&lt;/code&gt; instead of &lt;code&gt;-av&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Shut Down WSL
&lt;/h3&gt;

&lt;p&gt;The VHDX file must not be in use when you compact it. From PowerShell or Command Prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--shutdown&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This stops all running distributions and the WSL2 VM. Wait a few seconds for the filesystem to fully release.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Compact the VHDX
&lt;/h3&gt;

&lt;p&gt;You have two options here, depending on your Windows setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A: Optimize-VHD (Hyper-V required)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you have Hyper-V enabled (Windows Pro/Enterprise), this is the faster and more reliable method. Run PowerShell as Administrator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Optimize-VHD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;LOCALAPPDATA&lt;/span&gt;&lt;span class="s2"&gt;\wsl\{distro-guid}\ext4.vhdx"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Full&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option B: diskpart (works everywhere)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you don't have Hyper-V, &lt;code&gt;diskpart&lt;/code&gt; is built into every Windows installation. Run Command Prompt as Administrator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;diskpart
select vdisk file="C:\Users\YourName\AppData\Local\wsl\{distro-guid}\ext4.vhdx"
compact vdisk
exit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait for the "DiskPart successfully compacted the virtual disk file" message.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Verify
&lt;/h3&gt;

&lt;p&gt;Start your distro again and check the Windows file size:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-d&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;echo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Distro started"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Get-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;LOCALAPPDATA&lt;/span&gt;&lt;span class="s2"&gt;\wsl\{distro-guid}\ext4.vhdx"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="n"&gt;Select-Object&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;@{&lt;/span&gt;&lt;span class="nx"&gt;N&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'SizeGB'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="nx"&gt;E&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{[&lt;/span&gt;&lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;]::&lt;/span&gt;&lt;span class="n"&gt;Round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Length&lt;/span&gt;&lt;span class="n"&gt;/1GB&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2&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;You should see a smaller file size. In practice, I've seen compaction reclaim anywhere from a few hundred MB to tens of GB depending on how long the distro has been running.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enabling Sparse Mode (Automatic Reclamation)
&lt;/h2&gt;

&lt;p&gt;Manually compacting is fine occasionally, but WSL has a better long-term solution: &lt;strong&gt;sparse mode&lt;/strong&gt;. When enabled, WSL automatically reclaims disk space as you delete files — no manual compaction needed.&lt;/p&gt;

&lt;p&gt;Enable it per-distro:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--manage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;distro-name&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--set-sparse&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--manage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--set-sparse&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To disable it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--manage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;distro-name&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--set-sparse&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Things to know about sparse mode:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It works on a per-distribution basis&lt;/li&gt;
&lt;li&gt;There's a small performance overhead for the automatic reclamation&lt;/li&gt;
&lt;li&gt;It's most effective on distros where you frequently create and delete large files (build artifacts, container images, etc.)&lt;/li&gt;
&lt;li&gt;Your distro needs to be stopped when you enable it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Resizing the Virtual Disk
&lt;/h2&gt;

&lt;p&gt;Sometimes you need the opposite — more space. WSL2 defaults to a 1TB maximum virtual disk size, but you can expand it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;wsl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--manage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;distro-name&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--resize&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;512GB&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This increases the maximum size the VHDX can grow to. You can only increase the size, not decrease it. After resizing, you may need to extend the filesystem inside the distro:&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;resize2fs /dev/sdd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Easy Way: WSL UI
&lt;/h2&gt;

&lt;p&gt;All of these steps — finding VHDX files, running fstrim, shutting down WSL, choosing between Optimize-VHD and diskpart — are exactly the kind of multi-step process that's easy to get wrong.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://wsl-ui.octasoft.co.uk" rel="noopener noreferrer"&gt;WSL UI&lt;/a&gt; handles all of this with a single click. The compact feature:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Runs &lt;code&gt;fstrim&lt;/code&gt; inside the distro automatically&lt;/li&gt;
&lt;li&gt;Shuts down WSL and waits for the file lock to release&lt;/li&gt;
&lt;li&gt;Tries &lt;code&gt;Optimize-VHD&lt;/code&gt; first, falls back to &lt;code&gt;diskpart&lt;/code&gt; if Hyper-V isn't available&lt;/li&gt;
&lt;li&gt;Shows you the before/after sizes and how much space was saved&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It also shows disk usage in the main dashboard, so you can spot distros that need attention before your C: drive fills up.&lt;/p&gt;

&lt;p&gt;Sparse mode is a toggle in the distribution settings — no command line needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prevention Tips
&lt;/h2&gt;

&lt;p&gt;A few things you can do to keep disk growth under control:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Enable sparse mode&lt;/strong&gt; on distros where you do heavy development&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clean package caches regularly:&lt;/strong&gt; &lt;code&gt;sudo apt clean&lt;/code&gt; (Ubuntu/Debian) or &lt;code&gt;sudo dnf clean all&lt;/code&gt; (Fedora)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Watch Docker/Podman storage&lt;/strong&gt; — container images inside WSL are a common space hog. Run &lt;code&gt;docker system prune&lt;/code&gt; or &lt;code&gt;podman system prune&lt;/code&gt; periodically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;--location&lt;/code&gt; when installing&lt;/strong&gt; — &lt;code&gt;wsl --install Ubuntu --location D:\WSL\Ubuntu&lt;/code&gt; puts the VHDX on a different drive from the start&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compact before and after major cleanup&lt;/strong&gt; — if you're about to delete a lot of data, compact afterward to actually reclaim the space&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Check VHDX size&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Get-Item path\ext4.vhdx&lt;/code&gt; in PowerShell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check Linux usage&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;df -h /&lt;/code&gt; inside distro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Zero free space&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sudo fstrim -av&lt;/code&gt; inside distro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shut down WSL&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --shutdown&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compact (Hyper-V)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Optimize-VHD -Path "..." -Mode Full&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compact (built-in)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;diskpart&lt;/code&gt; → &lt;code&gt;select vdisk&lt;/code&gt; → &lt;code&gt;compact vdisk&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enable sparse mode&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --manage &amp;lt;distro&amp;gt; --set-sparse true&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resize disk&lt;/td&gt;
&lt;td&gt;&lt;code&gt;wsl --manage &amp;lt;distro&amp;gt; --resize 512GB&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Your WSL distributions don't need to eat your entire C: drive. A bit of maintenance goes a long way.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/managing-wsl-disk-space" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/managing-wsl-disk-space&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>wsl</category>
      <category>windows</category>
      <category>diskmanagement</category>
      <category>vhdx</category>
    </item>
    <item>
      <title>Automated TLS Certificates with Let's Encrypt and DNS-01 Challenges</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Sun, 01 Feb 2026 23:49:06 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/automated-tls-certificates-with-lets-encrypt-and-dns-01-challenges-b2n</link>
      <guid>https://dev.to/octasoft-ltd/automated-tls-certificates-with-lets-encrypt-and-dns-01-challenges-b2n</guid>
      <description>&lt;p&gt;HTTPS everywhere isn't optional anymore. Browsers flag HTTP as insecure, service meshes expect mTLS, and APIs should be encrypted in transit. But managing TLS certificates manually is tedious - renewals every 90 days, CSR generation, key management, distribution to load balancers.&lt;/p&gt;

&lt;p&gt;Let's Encrypt solved the cost problem (free certificates) and the validation problem (automated domain verification). cert-manager brings this to Kubernetes with native integration. This post covers how to set up fully automated TLS certificate management using DNS-01 challenges with AWS Route53.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Series context&lt;/strong&gt;: This is Part 6 of the &lt;a href="https://dev.to/blog/homelab-part-1-the-great-wsl-escape"&gt;Homelab Kubernetes Series&lt;/a&gt;. We've covered &lt;a href="https://dev.to/blog/homelab-part-2-bootstrap"&gt;bootstrapping&lt;/a&gt;, &lt;a href="https://dev.to/blog/homelab-part-3-gitops"&gt;GitOps with ArgoCD&lt;/a&gt;, &lt;a href="https://dev.to/blog/homelab-part-4-service-mesh"&gt;service mesh setup&lt;/a&gt;, and &lt;a href="https://dev.to/blog/secrets-management-infisical-external-secrets"&gt;secrets management&lt;/a&gt;. Now we're adding automated TLS certificate management to complete the security layer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why DNS-01 Over HTTP-01
&lt;/h2&gt;

&lt;p&gt;Let's Encrypt supports two main challenge types for proving domain ownership:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HTTP-01&lt;/strong&gt;: Let's Encrypt requests a file at &lt;code&gt;http://yourdomain.com/.well-known/acme-challenge/token&lt;/code&gt;. Your server must respond with the correct token.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DNS-01&lt;/strong&gt;: Let's Encrypt queries a TXT record at &lt;code&gt;_acme-challenge.yourdomain.com&lt;/code&gt;. You create this record with the token value.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-6-tls-certificates%2Fdns01-flow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-6-tls-certificates%2Fdns01-flow.png" alt="homelab-part-6-tls-certificates/dns01-flow" width="800" height="206"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For many Kubernetes deployments, DNS-01 is the better choice:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;HTTP-01&lt;/th&gt;
&lt;th&gt;DNS-01&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Public-facing services&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Private/internal services&lt;/td&gt;
&lt;td&gt;Fails&lt;/td&gt;
&lt;td&gt;Works&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Wildcard certificates&lt;/td&gt;
&lt;td&gt;Not supported&lt;/td&gt;
&lt;td&gt;Supported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Firewall restrictions&lt;/td&gt;
&lt;td&gt;Needs port 80 open&lt;/td&gt;
&lt;td&gt;No inbound ports&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Load balancer complexity&lt;/td&gt;
&lt;td&gt;Needs routing&lt;/td&gt;
&lt;td&gt;DNS only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your services aren't publicly accessible - homelab, private cloud, internal APIs - HTTP-01 won't work. DNS-01 proves you control the domain regardless of where your services run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Components
&lt;/h2&gt;

&lt;p&gt;The setup involves four pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;cert-manager&lt;/strong&gt; - Kubernetes controller that manages certificate lifecycle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let's Encrypt&lt;/strong&gt; - Free certificate authority with ACME protocol support&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Route53&lt;/strong&gt; - DNS provider (or your DNS provider of choice)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External Secrets&lt;/strong&gt; - Secure credential management for AWS access&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;cert-manager watches for Certificate resources, contacts Let's Encrypt, solves the DNS challenge by creating Route53 records, and stores the issued certificate as a Kubernetes Secret.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing cert-manager
&lt;/h2&gt;

&lt;p&gt;cert-manager installs via Helm. The key configuration enables Gateway API support and Prometheus metrics:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://charts.jetstack.io&lt;/span&gt;
    &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.18.2&lt;/span&gt;

    &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;# Install CRDs with the chart&lt;/span&gt;
        &lt;span class="s"&gt;installCRDs: true&lt;/span&gt;

        &lt;span class="s"&gt;# Resource limits for homelab/small clusters&lt;/span&gt;
        &lt;span class="s"&gt;resources:&lt;/span&gt;
          &lt;span class="s"&gt;requests:&lt;/span&gt;
            &lt;span class="s"&gt;cpu: 20m&lt;/span&gt;
            &lt;span class="s"&gt;memory: 64Mi&lt;/span&gt;
          &lt;span class="s"&gt;limits:&lt;/span&gt;
            &lt;span class="s"&gt;cpu: 200m&lt;/span&gt;
            &lt;span class="s"&gt;memory: 256Mi&lt;/span&gt;

        &lt;span class="s"&gt;webhook:&lt;/span&gt;
          &lt;span class="s"&gt;resources:&lt;/span&gt;
            &lt;span class="s"&gt;requests:&lt;/span&gt;
              &lt;span class="s"&gt;cpu: 5m&lt;/span&gt;
              &lt;span class="s"&gt;memory: 16Mi&lt;/span&gt;
            &lt;span class="s"&gt;limits:&lt;/span&gt;
              &lt;span class="s"&gt;cpu: 50m&lt;/span&gt;
              &lt;span class="s"&gt;memory: 64Mi&lt;/span&gt;

        &lt;span class="s"&gt;cainjector:&lt;/span&gt;
          &lt;span class="s"&gt;resources:&lt;/span&gt;
            &lt;span class="s"&gt;requests:&lt;/span&gt;
              &lt;span class="s"&gt;cpu: 5m&lt;/span&gt;
              &lt;span class="s"&gt;memory: 16Mi&lt;/span&gt;
            &lt;span class="s"&gt;limits:&lt;/span&gt;
              &lt;span class="s"&gt;cpu: 100m&lt;/span&gt;
              &lt;span class="s"&gt;memory: 128Mi&lt;/span&gt;

        &lt;span class="s"&gt;# Prometheus metrics&lt;/span&gt;
        &lt;span class="s"&gt;prometheus:&lt;/span&gt;
          &lt;span class="s"&gt;enabled: true&lt;/span&gt;
          &lt;span class="s"&gt;servicemonitor:&lt;/span&gt;
            &lt;span class="s"&gt;enabled: true&lt;/span&gt;

        &lt;span class="s"&gt;# Enable Gateway API integration&lt;/span&gt;
        &lt;span class="s"&gt;extraArgs:&lt;/span&gt;
          &lt;span class="s"&gt;- --enable-gateway-api&lt;/span&gt;

        &lt;span class="s"&gt;# Security&lt;/span&gt;
        &lt;span class="s"&gt;securityContext:&lt;/span&gt;
          &lt;span class="s"&gt;runAsNonRoot: true&lt;/span&gt;

  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--enable-gateway-api&lt;/code&gt; flag is important if you're using Gateway API instead of Ingress. As we covered in &lt;a href="https://dev.to/blog/homelab-part-4-service-mesh"&gt;Part 4 (Service Mesh)&lt;/a&gt;, cert-manager can automatically provision certificates for Gateway listeners.&lt;/p&gt;

&lt;h2&gt;
  
  
  AWS IAM Setup
&lt;/h2&gt;

&lt;p&gt;cert-manager needs permission to create DNS records in Route53. The principle of least privilege means giving it exactly what it needs - and for ACME DNS-01 challenges, that's surprisingly narrow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;IAM architecture options:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;IAM User with direct permissions&lt;/strong&gt; - Simple, but the user has standing Route53 access&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAM Role with IRSA&lt;/strong&gt; (EKS) - Pod assumes role via service account, no static credentials&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAM User + Role assumption&lt;/strong&gt; - User can only assume a role, role has the actual permissions&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I use option 3. The IAM user has no Route53 permissions at all - its only ability is to assume a specific role. This separates identity (the user) from capability (the role), and means compromised credentials can't do anything without also compromising the role assumption.&lt;/p&gt;

&lt;h3&gt;
  
  
  The IAM User (Identity Only)
&lt;/h3&gt;

&lt;p&gt;The user exists solely to provide credentials that can assume the role:&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"sts:TagSession"&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;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::ACCOUNT_ID:role/CertManagerDNSRole"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. The user can assume exactly one role and nothing else.&lt;/p&gt;

&lt;h3&gt;
  
  
  The IAM Role (Capability)
&lt;/h3&gt;

&lt;p&gt;The role has the actual Route53 permissions, tightly scoped for ACME challenges:&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"route53:GetChange"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:route53:::change/*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"route53:ChangeResourceRecordSets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"route53:ListResourceRecordSets"&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;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:route53:::hostedzone/ZONE_ID_HERE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&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;"ForAllValues:StringEquals"&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;"route53:ChangeResourceRecordSetsRecordTypes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TXT"&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;"ForAllValues:StringLike"&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;"route53:ChangeResourceRecordSetsRecordNames"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"_acme-challenge.*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"route53:ListHostedZonesByName"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"route53:ListHostedZones"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"route53:GetHostedZone"&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;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The conditions are key:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ChangeResourceRecordSetsRecordTypes: ["TXT"]&lt;/code&gt; - Can only modify TXT records&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ChangeResourceRecordSetsRecordNames: ["_acme-challenge.*"]&lt;/code&gt; - Can only modify records starting with &lt;code&gt;_acme-challenge.&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even if the role is compromised, it can't touch your A records, MX records, or anything else. It can only create and delete the specific TXT records needed for ACME validation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trust Policy (Who Can Assume)
&lt;/h3&gt;

&lt;p&gt;The role's trust policy specifies which principals can assume it:&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;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&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;"AWS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::ACCOUNT_ID:user/service-accounts/certmanager-dns-challenge"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the specific cert-manager IAM user can assume this role. Combined with the scoped permissions, this creates defence in depth: compromise the user credentials, and you can only assume this one role; compromise the role, and you can only modify ACME challenge records.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-6-tls-certificates%2Fiam-role-assumption.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-6-tls-certificates%2Fiam-role-assumption.png" alt="homelab-part-6-tls-certificates/iam-role-assumption" width="800" height="131"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Credential Management
&lt;/h2&gt;

&lt;p&gt;The IAM access key and secret need to reach cert-manager. Hardcoding them in manifests is a security incident waiting to happen. As covered in &lt;a href="https://dev.to/blog/secrets-management-infisical-external-secrets"&gt;Part 5 (Secrets Management)&lt;/a&gt;, I use External Secrets Operator to pull credentials from Infisical:&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;external-secrets.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;ExternalSecret&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;route53-credentials-external&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;cert-manager&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;refreshInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
  &lt;span class="na"&gt;secretStoreRef&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;infisical-cluster-secretstore&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;ClusterSecretStore&lt;/span&gt;
  &lt;span class="na"&gt;target&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;route53-credentials&lt;/span&gt;
    &lt;span class="na"&gt;creationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Owner&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;access-key-id&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/cert-manager/AWS_ACCESS_KEY_ID&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secret-access-key&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/cert-manager/AWS_SECRET_ACCESS_KEY&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a Kubernetes Secret named &lt;code&gt;route53-credentials&lt;/code&gt; in the &lt;code&gt;cert-manager&lt;/code&gt; namespace, refreshed every 15 minutes. If you rotate the AWS credentials in your secrets manager, they propagate automatically.&lt;/p&gt;

&lt;p&gt;For simpler setups without a secrets manager, create the secret directly (but don't commit it to Git):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic route53-credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; cert-manager &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;access-key-id&lt;span class="o"&gt;=&lt;/span&gt;AKIAIOSFODNN7EXAMPLE &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;secret-access-key&lt;span class="o"&gt;=&lt;/span&gt;wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The ClusterIssuer
&lt;/h2&gt;

&lt;p&gt;A ClusterIssuer defines how cert-manager obtains certificates. For Let's Encrypt with DNS-01:&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="c1"&gt;# Let's Encrypt production server&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="c1"&gt;# Email for expiry notifications and account recovery&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;your-email@example.com&lt;/span&gt;

    &lt;span class="c1"&gt;# Secret to store the ACME account private key&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&lt;/span&gt;

    &lt;span class="c1"&gt;# DNS-01 solver configuration&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;route53&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
          &lt;span class="na"&gt;hostedZoneID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Z0123456789ABCDEFGHIJ"&lt;/span&gt;

          &lt;span class="c1"&gt;# Reference to the credentials secret&lt;/span&gt;
          &lt;span class="na"&gt;accessKeyIDSecretRef&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;route53-credentials&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;access-key-id&lt;/span&gt;
          &lt;span class="na"&gt;secretAccessKeySecretRef&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;route53-credentials&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;secret-access-key&lt;/span&gt;

          &lt;span class="c1"&gt;# IAM role to assume (optional but recommended)&lt;/span&gt;
          &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:iam::ACCOUNT_ID:role/CertManagerDNSRole"&lt;/span&gt;

      &lt;span class="c1"&gt;# Only use this solver for specific domains&lt;/span&gt;
      &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;dnsZones&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;example.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key fields explained:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;server&lt;/code&gt;: Use staging (&lt;code&gt;acme-staging-v02.api.letsencrypt.org&lt;/code&gt;) for testing to avoid rate limits&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;email&lt;/code&gt;: Let's Encrypt sends expiry warnings here (cert-manager renews automatically, but good to have)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;privateKeySecretRef&lt;/code&gt;: cert-manager creates this secret to store your ACME account key&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;hostedZoneID&lt;/code&gt;: Your Route53 zone ID (find it in the AWS console)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;role&lt;/code&gt;: Optional IAM role to assume - if specified, cert-manager uses STS AssumeRole&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;selector.dnsZones&lt;/code&gt;: Restricts this solver to specific domains (useful with multiple issuers)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Always test with staging first.&lt;/strong&gt; Let's Encrypt has strict rate limits (50 certificates per domain per week). Staging certificates aren't trusted by browsers but validate your configuration works.&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-staging&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-staging-v02.api.letsencrypt.org/directory&lt;/span&gt;
    &lt;span class="c1"&gt;# ... rest identical to production&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Requesting Certificates
&lt;/h2&gt;

&lt;p&gt;With the ClusterIssuer configured, request a certificate:&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;my-app-tls&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;my-app&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="c1"&gt;# Where to store the certificate&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;my-app-tls-cert&lt;/span&gt;

  &lt;span class="c1"&gt;# Which issuer to use&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-prod&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="c1"&gt;# Domains to include&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="s"&gt;app.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;*.app.example.com"&lt;/span&gt;  &lt;span class="c1"&gt;# Wildcard - requires DNS-01&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;cert-manager:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Contacts Let's Encrypt to start the ACME flow&lt;/li&gt;
&lt;li&gt;Creates a TXT record in Route53: &lt;code&gt;_acme-challenge.app.example.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Tells Let's Encrypt to verify the record&lt;/li&gt;
&lt;li&gt;Receives the signed certificate&lt;/li&gt;
&lt;li&gt;Stores it in the &lt;code&gt;my-app-tls-cert&lt;/code&gt; Secret&lt;/li&gt;
&lt;li&gt;Cleans up the DNS record&lt;/li&gt;
&lt;li&gt;Renews automatically before expiry (default: 30 days before)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The resulting secret contains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tls.crt&lt;/code&gt; - The certificate chain (your cert + Let's Encrypt intermediate)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tls.key&lt;/code&gt; - The private key&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Using Certificates
&lt;/h2&gt;

&lt;p&gt;Kubernetes has two approaches for routing external traffic: the original &lt;strong&gt;Ingress&lt;/strong&gt; API and the newer &lt;strong&gt;Gateway API&lt;/strong&gt;. Gateway API is the successor - more expressive, role-oriented, and now the recommended approach for new deployments.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gateway API (Recommended)
&lt;/h3&gt;

&lt;p&gt;Gateway API separates concerns: infrastructure teams manage Gateways (the load balancer configuration), application teams manage HTTPRoutes (where traffic goes). As we set up in &lt;a href="https://dev.to/blog/homelab-part-4-service-mesh"&gt;Part 4 (Service Mesh)&lt;/a&gt;, the Gateway references the TLS certificate:&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;gateway.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;Gateway&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;main-gateway&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;istio-ingress&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;gatewayClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;istio&lt;/span&gt;
  &lt;span class="na"&gt;listeners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;
    &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
    &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPS&lt;/span&gt;
    &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terminate&lt;/span&gt;
      &lt;span class="na"&gt;certificateRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app-tls-cert&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;istio-ingress&lt;/span&gt;
    &lt;span class="na"&gt;allowedRoutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;All&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Services then attach to the Gateway via HTTPRoute resources. The HTTPRoute doesn't need to know about TLS - the Gateway handles termination:&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;gateway.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;HTTPRoute&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;my-app&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;my-app&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;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main-gateway&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;istio-ingress&lt;/span&gt;
  &lt;span class="na"&gt;hostnames&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;app.example.com"&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;matches&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="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PathPrefix&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
    &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-app&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This separation means one wildcard certificate on the Gateway serves all HTTPRoutes - you don't need per-service certificates.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-6-tls-certificates%2Fgateway-certificate-flow.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-6-tls-certificates%2Fgateway-certificate-flow.png" alt="homelab-part-6-tls-certificates/gateway-certificate-flow" width="800" height="689"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Ingress (Legacy Approach)
&lt;/h3&gt;

&lt;p&gt;Ingress combines routing and TLS in a single resource. It still works and is widely supported, but lacks the flexibility of Gateway API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;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;my-app&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;cert-manager.io/cluster-issuer&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;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="s"&gt;app.example.com&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;my-app-tls-cert&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;app.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;my-app&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;With the annotation, cert-manager automatically creates a Certificate resource when you create the Ingress. This is convenient but couples certificate management to the Ingress resource.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which to Choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Consideration&lt;/th&gt;
&lt;th&gt;Gateway API&lt;/th&gt;
&lt;th&gt;Ingress&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;New projects&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Recommended&lt;/td&gt;
&lt;td&gt;Works, but why?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Existing Ingress setup&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Migrate when ready&lt;/td&gt;
&lt;td&gt;Continue using&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Multi-team environments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Better separation of concerns&lt;/td&gt;
&lt;td&gt;Everyone edits same resources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Advanced routing&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Traffic splitting, header matching&lt;/td&gt;
&lt;td&gt;Basic path/host routing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Controller support&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Istio, Envoy, Nginx, Traefik&lt;/td&gt;
&lt;td&gt;Nearly universal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Check certificate status:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get certificates &lt;span class="nt"&gt;-A&lt;/span&gt;
kubectl describe certificate my-app-tls &lt;span class="nt"&gt;-n&lt;/span&gt; my-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check certificate request:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get certificaterequests &lt;span class="nt"&gt;-A&lt;/span&gt;
kubectl describe certificaterequest my-app-tls-xxxxx &lt;span class="nt"&gt;-n&lt;/span&gt; my-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Check challenges:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get challenges &lt;span class="nt"&gt;-A&lt;/span&gt;
kubectl describe challenge my-app-tls-xxxxx &lt;span class="nt"&gt;-n&lt;/span&gt; my-app
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Common issues:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Challenge stays pending&lt;/td&gt;
&lt;td&gt;AWS credentials wrong&lt;/td&gt;
&lt;td&gt;Check secret exists and has correct keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"no hosted zone found"&lt;/td&gt;
&lt;td&gt;Wrong zone ID or region&lt;/td&gt;
&lt;td&gt;Verify hostedZoneID matches Route53&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"access denied"&lt;/td&gt;
&lt;td&gt;IAM permissions insufficient&lt;/td&gt;
&lt;td&gt;Add required Route53 actions to policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"rate limited"&lt;/td&gt;
&lt;td&gt;Too many requests&lt;/td&gt;
&lt;td&gt;Wait, or use staging issuer for testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate not renewing&lt;/td&gt;
&lt;td&gt;cert-manager pod unhealthy&lt;/td&gt;
&lt;td&gt;Check logs: &lt;code&gt;kubectl logs -n cert-manager deploy/cert-manager&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Verify DNS propagation:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check if the challenge record exists&lt;/span&gt;
dig TXT _acme-challenge.app.example.com

&lt;span class="c"&gt;# Should return something like:&lt;/span&gt;
&lt;span class="c"&gt;# _acme-challenge.app.example.com. 300 IN TXT "abc123..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Other DNS Providers
&lt;/h2&gt;

&lt;p&gt;Route53 is one of many supported DNS providers. cert-manager has solvers for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt; - API token with Zone:DNS:Edit permission&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Cloud DNS&lt;/strong&gt; - Service account with dns.admin role&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure DNS&lt;/strong&gt; - Service principal with DNS Zone Contributor&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DigitalOcean&lt;/strong&gt; - API token&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RFC2136&lt;/strong&gt; - Dynamic DNS updates (for self-hosted DNS)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The configuration differs slightly per provider, but the pattern is the same: give cert-manager credentials to modify DNS records, configure the solver in your ClusterIssuer.&lt;/p&gt;

&lt;p&gt;Example for Cloudflare:&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;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;email&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-email@example.com&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;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Credential scope&lt;/strong&gt;: The DNS credentials can modify your domain's records. Scope them as tightly as possible - specific zones, specific record types if your provider supports it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Namespace isolation&lt;/strong&gt;: ClusterIssuer works across all namespaces. For multi-tenant clusters, consider namespace-scoped Issuer resources with separate credentials per tenant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Private key protection&lt;/strong&gt;: The certificate's private key is stored in a Kubernetes Secret. Enable encryption at rest for secrets, and use RBAC to restrict who can read them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits&lt;/strong&gt;: Let's Encrypt enforces rate limits. For production, this rarely matters (50 certs/domain/week). For development/testing, use the staging server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Automatic Gateway API certificates&lt;/strong&gt;: cert-manager can watch Gateway resources and automatically provision certificates for listeners. I'm not using this yet - explicitly creating Certificate resources gives more control, but the automatic approach reduces boilerplate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple DNS providers&lt;/strong&gt;: For redundancy, you could configure multiple solvers with different DNS providers. If Route53 has issues, fall back to Cloudflare. Probably overkill for most setups, but possible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Certificate monitoring&lt;/strong&gt;: Prometheus metrics are enabled, but I haven't built dashboards for certificate expiry tracking. The &lt;code&gt;certmanager_certificate_expiration_timestamp_seconds&lt;/code&gt; metric exists - alerting on certificates expiring within 14 days would add defense in depth.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 6 of the &lt;a href="https://dev.to/blog/homelab-part-1-the-great-wsl-escape"&gt;Homelab Kubernetes Series&lt;/a&gt;, covering TLS and certificate automation for Kubernetes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://letsencrypt.org/" rel="noopener noreferrer"&gt;Let's Encrypt&lt;/a&gt; - Free, automated certificate authority&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cert-manager.io/docs/" rel="noopener noreferrer"&gt;cert-manager Documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc8555" rel="noopener noreferrer"&gt;ACME Protocol (RFC 8555)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/homelab-part-6-tls-certificates" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/homelab-part-6-tls-certificates&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>tls</category>
      <category>letsencrypt</category>
      <category>certmanager</category>
    </item>
    <item>
      <title>Secrets Management with Infisical and External Secrets Operator</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Sun, 01 Feb 2026 23:47:55 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/secrets-management-with-infisical-and-external-secrets-operator-2ghg</link>
      <guid>https://dev.to/octasoft-ltd/secrets-management-with-infisical-and-external-secrets-operator-2ghg</guid>
      <description>&lt;p&gt;GitOps has a fundamental tension: everything should be in Git, but secrets shouldn't be in Git. You need database passwords, API keys, and tokens to deploy applications, but committing them to a repository is a security incident waiting to happen.&lt;/p&gt;

&lt;p&gt;This post covers how to solve this with Infisical and External Secrets Operator (ESO) - a combination that keeps secrets out of Git while letting Kubernetes applications access them seamlessly.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Series context&lt;/strong&gt;: This post is part of the &lt;a href="https://dev.to/blog/homelab-part-1-the-great-wsl-escape"&gt;Homelab Kubernetes Series&lt;/a&gt;. In &lt;a href="https://dev.to/blog/homelab-part-2-bootstrap"&gt;Part 2 (Bootstrap)&lt;/a&gt;, I briefly mentioned using Infisical and ESO to fetch the ArgoCD password during cluster setup. This post goes deeper into the full secrets management architecture.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Problem: Secret Zero
&lt;/h2&gt;

&lt;p&gt;Every secrets management system has a bootstrapping problem. You need a secret to access your secrets manager. Where does that initial secret come from?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fsecrets-management-infisical-external-secrets%2Finfisical-eso-architecture.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fsecrets-management-infisical-external-secrets%2Finfisical-eso-architecture.png" alt="secrets-management-infisical-external-secrets/infisical-eso-architecture" width="800" height="229"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The options aren't great:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Environment variables on the host&lt;/strong&gt;: Someone has to set them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud IAM&lt;/strong&gt;: Requires cloud infrastructure and vendor lock-in&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mounted files&lt;/strong&gt;: Still need to get the file there somehow&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pragmatic approach: machine identity credentials stored locally, passed to scripts as environment variables. Not perfect, but contained to one location and never committed to Git.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Infisical
&lt;/h2&gt;

&lt;p&gt;I evaluated several options: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, and Infisical. For a homelab or small team, Infisical won for a few reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free tier&lt;/strong&gt; is generous enough for small-scale use&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simpler than Vault&lt;/strong&gt; - no unsealing ceremony, no complex HA setup&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-class External Secrets support&lt;/strong&gt; with a native provider&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EU hosting option&lt;/strong&gt; (eu.infisical.com) for data residency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Machine identity auth&lt;/strong&gt; designed for Kubernetes workloads&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The setup is: store secrets in Infisical's web UI or CLI, create a machine identity for the cluster, let ESO sync secrets into Kubernetes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing Your Infisical Region
&lt;/h2&gt;

&lt;p&gt;Infisical offers two hosted regions. Choose based on your data residency requirements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Region&lt;/th&gt;
&lt;th&gt;API URL&lt;/th&gt;
&lt;th&gt;Use Case&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;US (default)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://app.infisical.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Most users, no specific data residency needs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;EU&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;https://eu.infisical.com&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GDPR compliance, European data residency&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Throughout this post, examples use the US region (&lt;code&gt;app.infisical.com&lt;/code&gt;) as the default. If you need EU hosting, replace the domain in all configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Machine Identity
&lt;/h2&gt;

&lt;p&gt;Machine identities in Infisical use Universal Auth - a client ID and secret pair specifically for automated systems. No user login, no MFA prompts, just machine-to-machine authentication.&lt;/p&gt;

&lt;p&gt;In Infisical's web UI:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Within a project, go to &lt;strong&gt;Access Control &amp;gt; Machine Identities&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Add Machine Identity to Project&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Generate a client ID and client secret&lt;/li&gt;
&lt;li&gt;Save these somewhere secure (you'll need them for bootstrap and ongoing management)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsa2h0jefzg6ta50rah93.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsa2h0jefzg6ta50rah93.png" alt="Creating a machine identity in Infisical" width="800" height="243"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The identity needs access to read secrets from your project. Scope it to the appropriate environment with read-only access - it doesn't need to modify secrets, just fetch them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storing Configuration
&lt;/h2&gt;

&lt;p&gt;Before diving into implementation, establish where configuration lives. I use a &lt;code&gt;config.env&lt;/code&gt; file for non-secret values that both scripts and infrastructure-as-code tools can read:&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;# Infisical Configuration&lt;/span&gt;
&lt;span class="nv"&gt;INFISICAL_API_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://app.infisical.com"&lt;/span&gt;    &lt;span class="c"&gt;# or https://eu.infisical.com for EU&lt;/span&gt;
&lt;span class="nv"&gt;INFISICAL_PROJECT_SLUG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"my-project-slug"&lt;/span&gt;
&lt;span class="nv"&gt;INFISICAL_PROJECT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-project-uuid"&lt;/span&gt;
&lt;span class="nv"&gt;INFISICAL_ENVIRONMENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"dev"&lt;/span&gt;
&lt;span class="c"&gt;# Credentials come from environment variables, never stored in files&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual credentials (&lt;code&gt;INFISICAL_CLIENT_ID&lt;/code&gt; and &lt;code&gt;INFISICAL_CLIENT_SECRET&lt;/code&gt;) stay in environment variables, set before running any scripts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;INFISICAL_CLIENT_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-client-id"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;INFISICAL_CLIENT_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-client-secret"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This separation keeps configuration in version control while credentials stay out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bootstrap: Fetching Initial Secrets
&lt;/h2&gt;

&lt;p&gt;During cluster bootstrap, ESO isn't installed yet. Use the Infisical CLI directly to fetch any secrets needed for initial setup (like an ArgoCD admin password).&lt;/p&gt;

&lt;p&gt;Install the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-1sLf&lt;/span&gt; &lt;span class="s1"&gt;'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh'&lt;/span&gt; | &lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; bash
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; infisical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authenticate and fetch a secret:&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;# Authenticate with machine identity&lt;/span&gt;
&lt;span class="nv"&gt;INFISICAL_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;infisical login &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"universal-auth"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INFISICAL_CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INFISICAL_CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://app.infisical.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--plain&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Fetch a specific secret&lt;/span&gt;
&lt;span class="nv"&gt;ARGOCD_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;infisical secrets get ARGOCD_ADMIN_PASSWORD &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/argocd"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"dev"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--projectId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INFISICAL_PROJECT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://app.infisical.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INFISICAL_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--plain&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# Clear token from memory when done&lt;/span&gt;
&lt;span class="nb"&gt;unset &lt;/span&gt;INFISICAL_TOKEN
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--plain&lt;/code&gt; flag returns just the value, no JSON wrapping. The &lt;code&gt;--silent&lt;/code&gt; flag suppresses progress output.&lt;/p&gt;

&lt;p&gt;Validate credentials early in your bootstrap script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;validate_environment&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INFISICAL_CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INFISICAL_CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Missing Infisical credentials"&lt;/span&gt;
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Please set: export INFISICAL_CLIENT_ID='...' INFISICAL_CLIENT_SECRET='...'"&lt;/span&gt;
        &lt;span class="nb"&gt;exit &lt;/span&gt;1
    &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Installing External Secrets Operator
&lt;/h2&gt;

&lt;p&gt;With the cluster running, install ESO via Helm:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm repo add external-secrets https://charts.external-secrets.io
helm repo update

helm upgrade &lt;span class="nt"&gt;--install&lt;/span&gt; external-secrets external-secrets/external-secrets &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; external-secrets &lt;span class="se"&gt;\&lt;/span&gt;
  &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; &lt;span class="nv"&gt;installCRDs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once installed, ESO watches for &lt;code&gt;ExternalSecret&lt;/code&gt; resources and syncs them into Kubernetes Secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the Credentials Secret
&lt;/h2&gt;

&lt;p&gt;ESO needs credentials to authenticate with Infisical. Create a Kubernetes Secret containing the machine identity:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create namespace platform-secrets

kubectl create secret generic infisical-credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; platform-secrets &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;client-id&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INFISICAL_CLIENT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;client-secret&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INFISICAL_CLIENT_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Or declaratively with Terraform/OpenTofu:&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="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"kubernetes_secret"&lt;/span&gt; &lt;span class="s2"&gt;"infisical_credentials"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"infisical-credentials"&lt;/span&gt;
    &lt;span class="nx"&gt;namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"platform-secrets"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"client-id"&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infisical_client_id&lt;/span&gt;
    &lt;span class="s2"&gt;"client-secret"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infisical_client_secret&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;
  
  
  Configuring the ClusterSecretStore
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;ClusterSecretStore&lt;/code&gt; tells ESO how to reach Infisical. This is cluster-wide, so any namespace can reference 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;external-secrets.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;ClusterSecretStore&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;infisical-cluster-secretstore&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;provider&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;infisical&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;hostAPI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://app.infisical.com&lt;/span&gt;  &lt;span class="c1"&gt;# or https://eu.infisical.com for EU&lt;/span&gt;

      &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;universalAuthCredentials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;clientId&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;infisical-credentials&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;client-id&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;platform-secrets&lt;/span&gt;
          &lt;span class="na"&gt;clientSecret&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;infisical-credentials&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;client-secret&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;platform-secrets&lt;/span&gt;

      &lt;span class="na"&gt;secretsScope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;projectSlug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-project-slug&lt;/span&gt;
        &lt;span class="na"&gt;environmentSlug&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dev&lt;/span&gt;
        &lt;span class="na"&gt;secretsPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; cluster-secret-store.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Using the Terraform Provider
&lt;/h2&gt;

&lt;p&gt;If you manage infrastructure with Terraform/OpenTofu, you can read secrets directly from Infisical. This is useful for configuring other providers (like ArgoCD) that need credentials.&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="nx"&gt;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;infisical&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Infisical/infisical"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 0.15"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"infisical"&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;"https://app.infisical.com"&lt;/span&gt;  &lt;span class="c1"&gt;# or https://eu.infisical.com for EU&lt;/span&gt;
  &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;universal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;client_id&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infisical_client_id&lt;/span&gt;
      &lt;span class="nx"&gt;client_secret&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infisical_client_secret&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;Fetch secrets as data sources:&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="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"infisical_secrets"&lt;/span&gt; &lt;span class="s2"&gt;"argocd"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;env_slug&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"dev"&lt;/span&gt;
  &lt;span class="nx"&gt;workspace_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infisical_project_id&lt;/span&gt;
  &lt;span class="nx"&gt;folder_path&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/argocd"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Use in other provider configurations&lt;/span&gt;
&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"argocd"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;infisical_secrets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;argocd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secrets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ARGOCD_ADMIN_PASSWORD"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets you bootstrap providers that need secrets without hardcoding values or using separate secret files.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important: State file security&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When Terraform/OpenTofu reads secrets, those values end up in the state file. This is a security consideration:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmvzdx8nnopfg8yxh3d7q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmvzdx8nnopfg8yxh3d7q.png" alt="secrets-management-infisical-external-secrets/terraform-state-security" width="800" height="225"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenTofu&lt;/strong&gt; supports native client-side state encryption (since 1.7) using AES-GCM with keys from PBKDF2, AWS KMS, GCP KMS, or OpenBao&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Terraform&lt;/strong&gt; does not have native state encryption - you must rely on encrypted backends (S3 with SSE, Terraform Cloud, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're storing secrets in state, OpenTofu's encryption feature is worth considering. Otherwise, ensure your state backend is properly secured and access-controlled.&lt;/p&gt;

&lt;h2&gt;
  
  
  ExternalSecret Patterns
&lt;/h2&gt;

&lt;p&gt;With the &lt;code&gt;ClusterSecretStore&lt;/code&gt; configured, applications request secrets via &lt;code&gt;ExternalSecret&lt;/code&gt; resources. These live in Git - they contain references to secrets, not the values themselves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Basic pattern - single secret:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;external-secrets.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;ExternalSecret&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;redis-credentials&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;redis&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;refreshInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
  &lt;span class="na"&gt;secretStoreRef&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;infisical-cluster-secretstore&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;ClusterSecretStore&lt;/span&gt;
  &lt;span class="na"&gt;target&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;redis-credentials&lt;/span&gt;
    &lt;span class="na"&gt;creationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Owner&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/redis/REDIS_PASSWORD"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Multiple secrets in one resource:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;external-secrets.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;ExternalSecret&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;minio-credentials&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;minio&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;refreshInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
  &lt;span class="na"&gt;secretStoreRef&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;infisical-cluster-secretstore&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;ClusterSecretStore&lt;/span&gt;
  &lt;span class="na"&gt;target&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;minio-credentials&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rootUser&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/minio/MINIO_ROOT_USER"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rootPassword&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/minio/MINIO_ROOT_PASSWORD"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Templated secrets with labels:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;external-secrets.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;ExternalSecret&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;gitlab-repo-credentials&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;refreshInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
  &lt;span class="na"&gt;secretStoreRef&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;infisical-cluster-secretstore&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;ClusterSecretStore&lt;/span&gt;
  &lt;span class="na"&gt;target&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;gitlab-repo&lt;/span&gt;
    &lt;span class="na"&gt;creationPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Owner&lt;/span&gt;
    &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;argocd.argoproj.io/secret-type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;repository&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git&lt;/span&gt;
        &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://gitlab.com/your-org/your-repo.git&lt;/span&gt;
        &lt;span class="na"&gt;username&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.username&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
        &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{{&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;.password&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;}}"&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;username&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/gitlab/DEPLOY_TOKEN_USERNAME"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;password&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/gitlab/DEPLOY_TOKEN_PASSWORD"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The template feature lets you construct complex secrets combining static values with fetched values.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Related&lt;/strong&gt;: See &lt;a href="https://dev.to/blog/gitlab-runner-on-kubernetes"&gt;GitLab Runner on Kubernetes&lt;/a&gt; for a practical example using ExternalSecrets for runner authentication.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Organising Secrets in Infisical
&lt;/h2&gt;

&lt;p&gt;Organise secrets by path for clarity:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/argocd/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;ArgoCD admin credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/gitlab/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;GitLab deploy tokens, runner tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/redis/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Redis authentication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/minio/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Object storage credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/grafana/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Monitoring credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/cert-manager/&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;DNS challenge credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern: &lt;code&gt;/&amp;lt;application&amp;gt;/&amp;lt;SECRET_NAME&amp;gt;&lt;/code&gt;. Clear, searchable, and easy to scope access.&lt;/p&gt;

&lt;p&gt;Types of secrets to store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Service credentials&lt;/strong&gt;: Database passwords, cache auth, object storage keys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform tokens&lt;/strong&gt;: Deploy tokens, runner registration tokens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud credentials&lt;/strong&gt;: IAM keys for cert-manager DNS validation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Application secrets&lt;/strong&gt;: API keys, admin passwords&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Refresh Cycle
&lt;/h2&gt;

&lt;p&gt;ESO polls on an interval, not continuously. Use &lt;code&gt;refreshInterval: 15m&lt;/code&gt; for most secrets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Secret rotation takes up to 15 minutes to propagate&lt;/li&gt;
&lt;li&gt;Reduces API calls to Infisical&lt;/li&gt;
&lt;li&gt;Acceptable latency for most use cases&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lower the interval for critical secrets requiring faster rotation. Increase it for static secrets that rarely change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What's protected:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No secrets in Git - ExternalSecrets reference paths, not values&lt;/li&gt;
&lt;li&gt;Machine identity credentials never committed&lt;/li&gt;
&lt;li&gt;Infisical handles encryption at rest and in transit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's not protected:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes Secrets are base64 encoded, not encrypted (unless you enable encryption at rest)&lt;/li&gt;
&lt;li&gt;Anyone with cluster access can read synced secrets&lt;/li&gt;
&lt;li&gt;The secret zero problem is pushed to the operator, not eliminated&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Recommendations:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enable Kubernetes encryption at rest for Secrets&lt;/li&gt;
&lt;li&gt;Use RBAC to restrict secret access by namespace&lt;/li&gt;
&lt;li&gt;Consider Sealed Secrets or SOPS for secrets that must be in Git&lt;/li&gt;
&lt;li&gt;Audit Infisical access logs periodically&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Complete Flow
&lt;/h2&gt;

&lt;p&gt;Putting it all together:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frjzv4bpe406pv7egnib2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frjzv4bpe406pv7egnib2.png" alt="secrets-management-infisical-external-secrets/complete-flow" width="628" height="2570"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Setup&lt;/strong&gt; (one-time): Create machine identity in Infisical, store client ID/secret locally&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bootstrap&lt;/strong&gt;: Script authenticates via CLI, fetches initial secrets, installs cluster components&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ESO Install&lt;/strong&gt;: External Secrets Operator deployed to cluster&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credentials&lt;/strong&gt;: Create the infisical-credentials Kubernetes Secret&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClusterSecretStore&lt;/strong&gt;: Configure ESO to connect to Infisical&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ExternalSecrets&lt;/strong&gt;: Deploy manifests that reference secrets by path&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync&lt;/strong&gt;: ESO watches ExternalSecrets, creates Kubernetes Secrets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumption&lt;/strong&gt;: Pods mount secrets normally - they don't know the source&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Applications see standard Kubernetes Secrets. ESO is the bridge.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Secret versioning&lt;/strong&gt;: Infisical supports secret versions. Pinning to specific versions would add safety during rotations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Backup strategy&lt;/strong&gt;: If Infisical is unavailable, ESO can't refresh secrets. Existing secrets persist, but new deployments might fail. A backup secret store would help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Audit integration&lt;/strong&gt;: Infisical has audit logs. Shipping these to your logging system would add visibility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Workload identity&lt;/strong&gt;: On cloud providers, workload identity (GKE, EKS IAM roles) eliminates the secret zero problem entirely.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 5 of the &lt;a href="https://dev.to/blog/homelab-part-1-the-great-wsl-escape"&gt;Homelab Kubernetes Series&lt;/a&gt;, covering secrets management patterns for Kubernetes. See also: &lt;a href="https://dev.to/blog/gitlab-runner-on-kubernetes"&gt;GitLab Runner on Kubernetes&lt;/a&gt; for a practical example of using External Secrets.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/secrets-management-infisical-external-secrets" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/secrets-management-infisical-external-secrets&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>secrets</category>
      <category>infisical</category>
      <category>externalsecrets</category>
    </item>
    <item>
      <title>Service Mesh Adventures - Cilium, Istio Ambient, and the Ztunnel Saga</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Sat, 31 Jan 2026 17:39:49 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/service-mesh-adventures-cilium-istio-ambient-and-the-ztunnel-saga-4f8p</link>
      <guid>https://dev.to/octasoft-ltd/service-mesh-adventures-cilium-istio-ambient-and-the-ztunnel-saga-4f8p</guid>
      <description>&lt;p&gt;This is the final part of my homelab series. We've covered the WSL/Hyper-V architecture, bootstrap scripts, and GitOps with ArgoCD. Now let's talk about the networking stack - and the ztunnel certificate issue that haunted me for weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Both Cilium AND Istio?
&lt;/h2&gt;

&lt;p&gt;A fair question. Cilium is a CNI that can do service mesh things. Istio is a service mesh. Why run both?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cilium handles L3/L4:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pod networking and IP address management&lt;/li&gt;
&lt;li&gt;Network policies&lt;/li&gt;
&lt;li&gt;eBPF-based observability (Hubble)&lt;/li&gt;
&lt;li&gt;Fast packet processing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Istio handles L7:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;mTLS between services (automatic encryption)&lt;/li&gt;
&lt;li&gt;Request-level routing (headers, paths, retries)&lt;/li&gt;
&lt;li&gt;Traffic splitting for canary deployments&lt;/li&gt;
&lt;li&gt;Distributed tracing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You &lt;em&gt;can&lt;/em&gt; do L7 with Cilium (via Envoy), and you &lt;em&gt;can&lt;/em&gt; do basic networking with Istio. But in my experience, letting each tool do what it does best gives the cleanest result.&lt;/p&gt;

&lt;p&gt;Also: I wanted to learn Istio's ambient mode. Running it alongside Cilium gave me that opportunity without replacing my working CNI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Istio Ambient Mode: No Sidecars
&lt;/h2&gt;

&lt;p&gt;Traditional Istio injects an Envoy sidecar into every pod. It works, but:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every pod needs extra CPU/memory for the sidecar&lt;/li&gt;
&lt;li&gt;Sidecar injection can cause deployment issues&lt;/li&gt;
&lt;li&gt;Debugging gets complicated with two containers per pod&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Ambient mode&lt;/strong&gt; takes a different approach. Instead of sidecars, it uses:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ztunnel&lt;/strong&gt;: A per-node DaemonSet that handles L4 mTLS&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;waypoint proxies&lt;/strong&gt;: Optional per-service L7 proxies (only where needed)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For my homelab, this means dramatically lower resource usage. Most services just need mTLS, not full L7 features, so ztunnel handles them without any sidecars.&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;# Enable ambient mode on a namespace&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Namespace&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;my-app&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;istio.io/dataplane-mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ambient&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. Pods in that namespace automatically get mTLS via ztunnel.&lt;/p&gt;

&lt;h2&gt;
  
  
  CNI Chaining: Making Them Play Nice
&lt;/h2&gt;

&lt;p&gt;Running Cilium and Istio together requires &lt;strong&gt;CNI chaining&lt;/strong&gt;. Both want to configure networking, so they need to cooperate.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-4-service-mesh%2Fhomelab-cni-chaining.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-4-service-mesh%2Fhomelab-cni-chaining.png" alt="homelab-part-4-service-mesh/homelab-cni-chaining" width="800" height="89"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The setup:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cilium installs its CNI config as &lt;code&gt;05-cilium.conflist&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Istio CNI installs as &lt;code&gt;ZZ-istio-cni.conflist&lt;/code&gt; (the "ZZ" ensures it loads after Cilium)&lt;/li&gt;
&lt;li&gt;Istio CNI chains onto Cilium rather than replacing it
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# From istiod Helm values&lt;/span&gt;
&lt;span class="na"&gt;cni&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;enabled&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;chained&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;cniConfFileName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ZZ-istio-cni.conflist"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Istio CNI doesn't do packet routing - Cilium handles that. It just sets up the identity and interception needed for ambient mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Ztunnel Certificate Nightmare
&lt;/h2&gt;

&lt;p&gt;Everything worked beautifully. For about a week. Then services started failing with TLS errors.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Pods could start but couldn't communicate&lt;/li&gt;
&lt;li&gt;Logs showed certificate validation failures&lt;/li&gt;
&lt;li&gt;Restarting pods temporarily fixed it&lt;/li&gt;
&lt;li&gt;The problem came back&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After much debugging, I found the culprit: &lt;strong&gt;ztunnel workload certificates were expiring&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Istio issues short-lived certificates (24 hours by default) to workloads. These should auto-renew. But in certain conditions - especially after VM suspend/resume cycles - ztunnel's certificate renewal would fail silently.&lt;/p&gt;

&lt;p&gt;The certificates would expire, mTLS would break, and nothing could talk to anything.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjr9lf8o6sals6ijew5w1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjr9lf8o6sals6ijew5w1.png" alt="Ztunnel alert firing for certificate issues" width="800" height="229"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Workaround: Weekly Restart
&lt;/h2&gt;

&lt;p&gt;I couldn't find a proper fix. The issue seems related to how ztunnel handles time jumps and certificate state after VM hibernation. Even with chrony fixing the clock, ztunnel's internal state was sometimes corrupt.&lt;/p&gt;

&lt;p&gt;My solution is crude but effective: a CronJob that restarts ztunnel weekly.&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;batch/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;CronJob&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;ztunnel-restart&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;istio-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;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;0"&lt;/span&gt;  &lt;span class="c1"&gt;# Sunday at 3 AM&lt;/span&gt;
  &lt;span class="na"&gt;jobTemplate&lt;/span&gt;&lt;span class="pi"&gt;:&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;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kubectl&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;bitnami/kubectl:latest&lt;/span&gt;
              &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;/bin/sh&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-c&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
                  &lt;span class="s"&gt;kubectl rollout restart daemonset/ztunnel -n istio-system&lt;/span&gt;
                  &lt;span class="s"&gt;kubectl rollout status daemonset/ztunnel -n istio-system --timeout=300s&lt;/span&gt;
          &lt;span class="na"&gt;restartPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OnFailure&lt;/span&gt;
          &lt;span class="na"&gt;serviceAccountName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ztunnel-restart-sa&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Is this ideal? No. Does it work? Yes. The rolling restart refreshes certificates and clears any stale state. I haven't had a certificate-related outage since.&lt;/p&gt;

&lt;p&gt;I also added alerts so I know if ztunnel is unhealthy between restarts:&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;# Prometheus alert rule&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ZtunnelCertificateExpiringSoon&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;istio_agent_cert_expiry_seconds{app="ztunnel"} &amp;lt; 3600&lt;/span&gt;
  &lt;span class="na"&gt;for&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5m&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;warning&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Ztunnel&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;certificate&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;expiring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;soon"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Gateway API: The Modern Ingress
&lt;/h2&gt;

&lt;p&gt;I use Gateway API instead of traditional Ingress resources. It's the future standard and works well with Istio.&lt;/p&gt;

&lt;p&gt;The setup:&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;# Gateway definition&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.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;Gateway&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;main-gateway&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;istio-ingress&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;gatewayClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;istio&lt;/span&gt;
  &lt;span class="na"&gt;listeners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt;
      &lt;span class="na"&gt;allowedRoutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;All&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;https&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;443&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPS&lt;/span&gt;
      &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Terminate&lt;/span&gt;
        &lt;span class="na"&gt;certificateRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;homelab-tls-cert&lt;/span&gt;
      &lt;span class="na"&gt;allowedRoutes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;namespaces&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;All&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Services expose themselves with HTTPRoutes:&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;gateway.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;HTTPRoute&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;grafana&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;monitoring&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;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main-gateway&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;istio-ingress&lt;/span&gt;
  &lt;span class="na"&gt;hostnames&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;grafana.homelab.example.com"&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;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana&lt;/span&gt;
          &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is cleaner than Ingress annotations. Each service owns its routing configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  MetalLB: LoadBalancer on Bare Metal
&lt;/h2&gt;

&lt;p&gt;The Gateway needs an external IP. On cloud providers, you'd get a LoadBalancer automatically. On bare metal (or a Hyper-V VM), you need MetalLB.&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;metallb.io/v1beta1&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;IPAddressPool&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;homelab-pool&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;metallb-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;addresses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;192.168.100.200-192.168.100.220&lt;/span&gt;
  &lt;span class="na"&gt;autoAssign&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;metallb.io/v1beta1&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;L2Advertisement&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;homelab-advertisement&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;metallb-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;ipAddressPools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;homelab-pool&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The IP range is on the same network as the VM (192.168.100.0/24). MetalLB uses L2 advertisement (ARP) to announce these IPs. From WSL2, I can reach &lt;code&gt;192.168.100.200&lt;/code&gt; (the Gateway's IP) directly.&lt;/p&gt;

&lt;p&gt;Combined with a wildcard DNS record (&lt;code&gt;*.homelab.example.com -&amp;gt; 192.168.100.200&lt;/code&gt;), every service gets its own hostname automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  TLS with Let's Encrypt
&lt;/h2&gt;

&lt;p&gt;cert-manager handles TLS certificates automatically:&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;your-email@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-key&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;route53&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;eu-west-2&lt;/span&gt;
            &lt;span class="na"&gt;hostedZoneID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;YOUR_ZONE_ID&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use DNS-01 challenges via Route 53. This works even though the services are private - Let's Encrypt validates DNS ownership, not HTTP reachability.&lt;/p&gt;

&lt;p&gt;The Gateway's TLS certificate auto-renews every 60 days. No manual intervention needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Full Networking Stack
&lt;/h2&gt;

&lt;p&gt;Here's how a request flows:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-4-service-mesh%2Fhomelab-network-stack.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-4-service-mesh%2Fhomelab-network-stack.png" alt="homelab-part-4-service-mesh/homelab-network-stack" width="800" height="64"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability
&lt;/h2&gt;

&lt;p&gt;With this stack, I get observability at multiple layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cilium Hubble&lt;/strong&gt;: L3/L4 network flows, DNS queries, dropped packets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Istio telemetry&lt;/strong&gt;: L7 request rates, latencies, error rates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grafana dashboards&lt;/strong&gt;: Everything visualised&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hubble is particularly useful for debugging. You can see exactly which pods are talking to which services and whether traffic is being allowed or denied.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm719v7xqt1xwkqjod29q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm719v7xqt1xwkqjod29q.png" alt="Hubble UI showing service-to-service communication" width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Ambient mode is promising but young.&lt;/strong&gt; The ztunnel certificate issue is a real pain. I'm betting on upstream fixes, but for now, the weekly restart workaround is necessary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. CNI chaining works, but read the docs carefully.&lt;/strong&gt; The order matters, the config file names matter, and debugging is harder when two CNIs are involved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Gateway API is worth adopting.&lt;/strong&gt; It's cleaner than Ingress, more expressive, and becoming the standard. Start using it now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Start simple, add complexity later.&lt;/strong&gt; I could have run just Cilium without Istio. Adding the service mesh was a learning exercise. For a production homelab, consider whether you actually need L7 features.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next for This Homelab
&lt;/h2&gt;

&lt;p&gt;Things I want to improve:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-node cluster&lt;/strong&gt;: Currently single-node. Adding worker nodes would let me test HA patterns and node failure scenarios.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better alerting&lt;/strong&gt;: The current setup has basic alerts. I want smarter alert routing and better runbooks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix ztunnel properly&lt;/strong&gt;: Keep watching upstream Istio for fixes to the certificate renewal issues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ArgoCD multi-namespace&lt;/strong&gt;: When ApplicationSets support multi-namespace properly, reorganise the GitOps structure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;This homelab journey started because I wanted to run Kubernetes on my Windows machine. I ended up with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Hyper-V VM because WSL2 networking doesn't support proper CNIs&lt;/li&gt;
&lt;li&gt;WSL2 mirrored networking for seamless access&lt;/li&gt;
&lt;li&gt;K3s with Cilium and Istio ambient mode&lt;/li&gt;
&lt;li&gt;Full GitOps with ArgoCD's app-of-apps pattern&lt;/li&gt;
&lt;li&gt;Automatic TLS with Let's Encrypt&lt;/li&gt;
&lt;li&gt;Comprehensive observability&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Is it over-engineered for a homelab? Probably. But I've learned a ton about Kubernetes networking, service meshes, and GitOps patterns. And I have a platform where I can deploy and test anything I'm working on.&lt;/p&gt;

&lt;p&gt;If you're considering a similar setup, I hope this series helps you avoid some of the pitfalls I hit. Good luck, and may your certificates never expire unexpectedly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This concludes the 4-part series on building a homelab Kubernetes setup on Windows. Thanks for reading!&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/homelab-part-4-service-mesh" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/homelab-part-4-service-mesh&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>istio</category>
      <category>cilium</category>
      <category>servicemesh</category>
    </item>
    <item>
      <title>GitOps All The Things - ArgoCD and the App-of-Apps Pattern</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Sat, 31 Jan 2026 17:39:08 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/gitops-all-the-things-argocd-and-the-app-of-apps-pattern-1i7f</link>
      <guid>https://dev.to/octasoft-ltd/gitops-all-the-things-argocd-and-the-app-of-apps-pattern-1i7f</guid>
      <description>&lt;p&gt;The bootstrap script from Part 2 gave us a cluster with ArgoCD installed. But ArgoCD is just sitting there, doing nothing. In this post, I'll explain how I use the &lt;strong&gt;app-of-apps pattern&lt;/strong&gt; to deploy and manage everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: Too Many Things to Deploy
&lt;/h2&gt;

&lt;p&gt;My homelab runs a lot of stuff:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Istio service mesh (4 components with specific ordering)&lt;/li&gt;
&lt;li&gt;cert-manager for TLS certificates&lt;/li&gt;
&lt;li&gt;MetalLB for LoadBalancer services&lt;/li&gt;
&lt;li&gt;PostgreSQL, Kafka, Redis, MinIO&lt;/li&gt;
&lt;li&gt;Grafana, Loki, Tempo, Mimir (the LGTM observability stack)&lt;/li&gt;
&lt;li&gt;A private Docker registry&lt;/li&gt;
&lt;li&gt;GitLab runners for CI/CD&lt;/li&gt;
&lt;li&gt;And more...&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I could deploy each of these manually with &lt;code&gt;helm install&lt;/code&gt; and &lt;code&gt;kubectl apply&lt;/code&gt;. But that's not GitOps. And it's definitely not repeatable when I inevitably nuke the cluster and start over.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: App-of-Apps
&lt;/h2&gt;

&lt;p&gt;The app-of-apps pattern is simple: create one ArgoCD Application that points to a directory of other Application manifests. ArgoCD reads the directory, creates all the Applications it finds, and each of those Applications deploys their respective workloads.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgk4kitgw45s7lbgrcvnq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgk4kitgw45s7lbgrcvnq.png" alt="homelab-part-3-gitops/homelab-app-of-apps" width="800" height="846"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One Application to rule them all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bootstrapping with OpenTofu
&lt;/h2&gt;

&lt;p&gt;The catch-22: ArgoCD needs the app-of-apps Application to exist, but we can't create it via GitOps because ArgoCD isn't managing anything yet.&lt;/p&gt;

&lt;p&gt;I use OpenTofu (Terraform fork) to create the initial app-of-apps:&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="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"argocd_application"&lt;/span&gt; &lt;span class="s2"&gt;"app_of_apps"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-of-apps"&lt;/span&gt;
    &lt;span class="nx"&gt;namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"argocd"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;spec&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;project&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"default"&lt;/span&gt;

    &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;repo_url&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://gitlab.com/your-org/homelab.git"&lt;/span&gt;
      &lt;span class="nx"&gt;path&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"argocd-apps"&lt;/span&gt;
      &lt;span class="nx"&gt;target_revision&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt;
      &lt;span class="nx"&gt;directory&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;recurse&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;destination&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;server&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://kubernetes.default.svc"&lt;/span&gt;
      &lt;span class="nx"&gt;namespace&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"argocd"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;sync_policy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;automated&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;prune&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
        &lt;span class="nx"&gt;self_heal&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;retry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;
        &lt;span class="nx"&gt;backoff&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;duration&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"5s"&lt;/span&gt;
          &lt;span class="nx"&gt;max_duration&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"3m"&lt;/span&gt;
          &lt;span class="nx"&gt;factor&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="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;p&gt;After &lt;code&gt;tofu apply&lt;/code&gt;, ArgoCD picks up the app-of-apps, reads the &lt;code&gt;argocd-apps/&lt;/code&gt; directory, and starts creating Applications.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjnobxwnnx1cs2lbqesxp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjnobxwnnx1cs2lbqesxp.png" alt="homelab-part-3-gitops/argocd-app-of-apps-tree" width="800" height="464"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sync Waves: Order Matters
&lt;/h2&gt;

&lt;p&gt;Some things need to be installed before others. You can't configure Istio routing until Istio is installed. You can't create Certificates until cert-manager is running.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-3-gitops%2Fhomelab-sync-waves.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-3-gitops%2Fhomelab-sync-waves.png" alt="homelab-part-3-gitops/homelab-sync-waves" width="800" height="91"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;ArgoCD's &lt;strong&gt;sync waves&lt;/strong&gt; solve this. Each Application gets a sync wave annotation:&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;# istio-base.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;istio-base&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/sync-wave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1"&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;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://istio-release.storage.googleapis.com/charts&lt;/span&gt;
    &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;base&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.23.2&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;

&lt;span class="c1"&gt;# istio-cni.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;istio-cni&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/sync-wave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2"&lt;/span&gt;
&lt;span class="c1"&gt;# ...&lt;/span&gt;

&lt;span class="c1"&gt;# istiod.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;istiod&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;argocd.argoproj.io/sync-wave&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3"&lt;/span&gt;
&lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ArgoCD deploys wave 1 first, waits for it to be healthy, then wave 2, and so on. For Istio, this means:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;istio-base&lt;/code&gt; (CRDs and basic resources)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;istio-cni&lt;/code&gt; (CNI plugin for ambient mode)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;istiod&lt;/code&gt; (control plane)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ztunnel&lt;/code&gt; (ambient mode data plane)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Without sync waves, ArgoCD would try to deploy everything at once and fail because CRDs don't exist yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multisource Applications
&lt;/h2&gt;

&lt;p&gt;Some deployments need both a Helm chart AND custom manifests. For example, MinIO needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The official MinIO Helm chart&lt;/li&gt;
&lt;li&gt;Custom HTTPRoute for Gateway API ingress&lt;/li&gt;
&lt;li&gt;ExternalSecret to pull credentials from Infisical&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ArgoCD's multisource feature handles this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio&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;sources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Source 1: Official Helm chart&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://charts.min.io/&lt;/span&gt;
      &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio&lt;/span&gt;
      &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5.4.0&lt;/span&gt;
      &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;mode: standalone&lt;/span&gt;
          &lt;span class="s"&gt;replicas: 1&lt;/span&gt;
          &lt;span class="s"&gt;resources:&lt;/span&gt;
            &lt;span class="s"&gt;requests:&lt;/span&gt;
              &lt;span class="s"&gt;memory: 512Mi&lt;/span&gt;

    &lt;span class="c1"&gt;# Source 2: Custom manifests from our repo&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://gitlab.com/your-org/homelab.git&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;manifests&lt;/span&gt;
      &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;main&lt;/span&gt;
      &lt;span class="na"&gt;directory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;minio-*.yaml"&lt;/span&gt;

  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;minio&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;directory.include&lt;/code&gt; pattern lets me keep all my custom manifests in one &lt;code&gt;manifests/&lt;/code&gt; directory while only pulling the relevant ones for each application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-Healing and Auto-Sync
&lt;/h2&gt;

&lt;p&gt;Every Application has automated sync enabled:&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;sync_policy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;      &lt;span class="c1"&gt;# Delete resources removed from git&lt;/span&gt;
    &lt;span class="na"&gt;self_heal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;  &lt;span class="c1"&gt;# Revert manual changes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is GitOps in action. If someone manually edits a deployment, ArgoCD reverts it. If I delete an Application YAML from git, ArgoCD removes it from the cluster.&lt;/p&gt;

&lt;p&gt;The retry policy is important for the initial deployment:&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;retry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;
  &lt;span class="na"&gt;backoff&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5s"&lt;/span&gt;
    &lt;span class="na"&gt;max_duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3m"&lt;/span&gt;
    &lt;span class="na"&gt;factor&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Some Applications fail on first sync because dependencies aren't ready yet. The exponential backoff gives things time to settle.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Typical Application Manifest
&lt;/h2&gt;

&lt;p&gt;Here's what a standard Application 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="c1"&gt;# cert-manager.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argoproj.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;argocd&lt;/span&gt;
  &lt;span class="na"&gt;finalizers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;resources-finalizer.argocd.argoproj.io&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;

  &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;repoURL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://charts.jetstack.io&lt;/span&gt;
    &lt;span class="na"&gt;chart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager&lt;/span&gt;
    &lt;span class="na"&gt;targetRevision&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1.16.2&lt;/span&gt;
    &lt;span class="na"&gt;helm&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;crds:&lt;/span&gt;
          &lt;span class="s"&gt;enabled: true&lt;/span&gt;
        &lt;span class="s"&gt;resources:&lt;/span&gt;
          &lt;span class="s"&gt;requests:&lt;/span&gt;
            &lt;span class="s"&gt;cpu: 10m&lt;/span&gt;
            &lt;span class="s"&gt;memory: 32Mi&lt;/span&gt;

  &lt;span class="na"&gt;destination&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://kubernetes.default.svc&lt;/span&gt;
    &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cert-manager&lt;/span&gt;

  &lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;prune&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;selfHeal&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;syncOptions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CreateNamespace=true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Key points:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;finalizers&lt;/code&gt;: Ensures resources are cleaned up when Application is deleted&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CreateNamespace=true&lt;/code&gt;: ArgoCD creates the namespace if it doesn't exist&lt;/li&gt;
&lt;li&gt;Resource requests are tuned low - this is a homelab, not production&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The argocd-apps Directory Structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;argocd-apps/
├── argo-platform.yaml          # ArgoCD configuration
├── argo-projects.yaml          # ArgoCD projects
├── argo-repos.yaml             # Repository credentials
│
├── istio-base.yaml             # Sync wave 1
├── istio-cni.yaml              # Sync wave 2
├── istiod.yaml                 # Sync wave 3
├── ztunnel.yaml                # Sync wave 4
├── istio-gateway.yaml          # Gateway configuration
│
├── cilium.yaml                 # CNI management
├── metallb-system.yaml         # Load balancer
├── cert-manager.yaml           # TLS certificates
├── letsencrypt.yaml            # ACME issuer
│
├── postgres.yaml               # Database
├── kafka.yaml                  # Event streaming
├── redis-multisource.yaml      # Cache + custom routes
├── minio-multisource.yaml      # Object storage
│
├── lgtm-stack.yaml             # Observability
├── k8s-monitoring.yaml         # Metrics
│
└── ... (and more)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;30+ Applications, all managed from one directory. Add a new YAML file, commit, push, and ArgoCD deploys it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8fx7irj930v5up1dux52.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8fx7irj930v5up1dux52.png" alt="ArgoCD sync status showing all applications healthy" width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  External Secrets Integration
&lt;/h2&gt;

&lt;p&gt;I mentioned ExternalSecrets for MinIO. Here's how that works:&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;# manifests/minio-external-secret.yaml&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;external-secrets.io/v1beta1&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;ExternalSecret&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;minio-credentials&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;minio&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;refreshInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1h&lt;/span&gt;
  &lt;span class="na"&gt;secretStoreRef&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;infisical-store&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;ClusterSecretStore&lt;/span&gt;
  &lt;span class="na"&gt;target&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;minio-credentials&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rootUser&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/minio/MINIO_ROOT_USER&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;secretKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rootPassword&lt;/span&gt;
      &lt;span class="na"&gt;remoteRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/minio/MINIO_ROOT_PASSWORD&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Infisical holds the actual credentials. External Secrets Operator syncs them into Kubernetes Secrets. The Helm chart references the Secret. No credentials in git.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Joy of Drift Detection
&lt;/h2&gt;

&lt;p&gt;My favourite ArgoCD feature is seeing when something drifts from the desired state. Someone manually scaled a deployment? ArgoCD shows it as "OutOfSync" and reverts it.&lt;/p&gt;

&lt;p&gt;This has saved me multiple times when I "temporarily" changed something and forgot to revert it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;ArgoCD multi-namespace support&lt;/strong&gt;: Currently, all Applications live in the &lt;code&gt;argocd&lt;/code&gt; namespace. There's work in progress to support Applications in other namespaces, which would be cleaner for multi-tenant setups. When that lands, I'll reorganise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Application sets&lt;/strong&gt;: For similar applications (like per-environment deployments), ApplicationSets would reduce duplication. I haven't needed it yet for this homelab, but it's on the list.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The cluster is now deploying applications automatically. But we haven't talked about the service mesh yet - Cilium and Istio running together, and the ztunnel certificate issues that caused me grief. That's Part 4.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 3 of a 4-part series on building a homelab Kubernetes setup on Windows.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/homelab-part-3-gitops" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/homelab-part-3-gitops&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>argocd</category>
      <category>gitops</category>
      <category>homelab</category>
    </item>
    <item>
      <title>From Zero to K3s - Bootstrap Scripts and Time Sync Nightmares</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Sat, 31 Jan 2026 17:38:11 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/from-zero-to-k3s-bootstrap-scripts-and-time-sync-nightmares-43af</link>
      <guid>https://dev.to/octasoft-ltd/from-zero-to-k3s-bootstrap-scripts-and-time-sync-nightmares-43af</guid>
      <description>&lt;p&gt;In Part 1, I explained why my homelab runs in a Hyper-V VM instead of WSL2. Now let's talk about how I actually bootstrap the cluster - and the time synchronisation issue that had me questioning my life choices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Goal: One Script to Rule Them All
&lt;/h2&gt;

&lt;p&gt;I wanted a single &lt;code&gt;bootstrap.sh&lt;/code&gt; that could take a fresh Ubuntu VM and produce a working Kubernetes cluster with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;K3s as the distribution&lt;/li&gt;
&lt;li&gt;Cilium as the CNI (with Hubble for observability)&lt;/li&gt;
&lt;li&gt;Gateway API CRDs installed&lt;/li&gt;
&lt;li&gt;External Secrets Operator for secrets management&lt;/li&gt;
&lt;li&gt;ArgoCD for GitOps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The script needed to be &lt;strong&gt;idempotent&lt;/strong&gt; - safe to run multiple times. Because you &lt;em&gt;will&lt;/em&gt; run it multiple times while debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase-Based Installation
&lt;/h2&gt;

&lt;p&gt;The bootstrap script runs in phases. Each phase completes fully before the next begins, and each phase can be re-run independently if needed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-2-bootstrap%2Fhomelab-bootstrap-phases-1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-2-bootstrap%2Fhomelab-bootstrap-phases-1.png" alt="homelab-part-2-bootstrap/homelab-bootstrap-phases-1" width="800" height="89"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This structure saved me countless hours. When something broke in Phase 4, I didn't have to start from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 0: The Time Sync Disaster
&lt;/h2&gt;

&lt;p&gt;Let me tell you about the most frustrating bug I've encountered in this entire project.&lt;/p&gt;

&lt;p&gt;Everything would work perfectly after a fresh install. Then I'd close my laptop, come back the next day, resume the VM, and &lt;em&gt;chaos&lt;/em&gt;. Pods failing. Certificate errors everywhere. DNS not resolving. ArgoCD unable to sync.&lt;/p&gt;

&lt;p&gt;The culprit? &lt;strong&gt;Time drift&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Hyper-V VMs don't maintain accurate time when suspended. When you resume a VM that's been asleep for hours, the VM's clock can be significantly off. And Kubernetes &lt;em&gt;really&lt;/em&gt; doesn't like that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TLS certificates appear expired (or not yet valid)&lt;/li&gt;
&lt;li&gt;Tokens fail validation&lt;/li&gt;
&lt;li&gt;Let's Encrypt challenges time out&lt;/li&gt;
&lt;li&gt;Istio's mTLS goes haywire&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is &lt;code&gt;chrony&lt;/code&gt;, configured aggressively for VM environments:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;setup_time_sync&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; chrony

    &lt;span class="c"&gt;# Configure for VM environment with aggressive correction&lt;/span&gt;
    &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/chrony/chrony.conf &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
server time.google.com iburst
server time.cloudflare.com iburst
server pool.ntp.org iburst

# Allow instant time correction for any offset up to 1 day
makestep 86400 -1

# Log any time changes larger than 0.5 seconds
logchange 0.5
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;    &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart chrony
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is &lt;code&gt;makestep 86400 -1&lt;/code&gt;. This tells chrony to immediately step the clock (rather than gradually adjusting) for any offset up to 86400 seconds (24 hours), with no limit on how many times it can do this.&lt;/p&gt;

&lt;p&gt;After adding this, resume-from-suspend just works. Clock jumps forward, chrony notices, fixes it immediately, and everything continues.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Reference ID    : A29FC801 (time.cloudflare.com)
Stratum         : 4
Ref time (UTC)  : Thu Jan 16 10:23:45 2026
System time     : 0.000000023 seconds fast of NTP time
Last offset     : +0.000000012 seconds
RMS offset      : 0.000000156 seconds
Frequency       : 1.234 ppm slow
Residual freq   : +0.000 ppm
Skew            : 0.012 ppm
Root delay      : 0.012345678 seconds
Root dispersion : 0.000123456 seconds
Update interval : 1024.0 seconds
Leap status     : Normal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Phase 2: K3s Installation
&lt;/h2&gt;

&lt;p&gt;K3s is delightfully simple to install, but needs specific flags for our setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | &lt;span class="nv"&gt;INSTALL_K3S_EXEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"server &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --bind-address=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VM_IP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --advertise-address=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VM_IP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --disable=traefik &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --flannel-backend=none &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --disable-network-policy &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --cluster-cidr=10.42.0.0/16 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --service-cidr=10.43.0.0/16 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --write-kubeconfig-mode=644"&lt;/span&gt; sh -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why these flags:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--bind-address&lt;/code&gt; / &lt;code&gt;--advertise-address&lt;/code&gt;: Bind to VM IP, not localhost, so WSL2 can reach it&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--disable=traefik&lt;/code&gt;: We're using Istio Gateway, not Traefik&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--flannel-backend=none&lt;/code&gt;: Disables K3s's default CNI - we're using Cilium&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--disable-network-policy&lt;/code&gt;: Cilium handles network policies&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--write-kubeconfig-mode=644&lt;/code&gt;: Makes kubeconfig readable without sudo&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The script also updates the kubeconfig to use the VM's IP:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl config set-cluster default &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;VM_IP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;:6443
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-2-bootstrap%2Fhomelab-bootstrap-phases-2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwsl-ui.octasoft.co.uk%2Fdiagrams%2Fhomelab-part-2-bootstrap%2Fhomelab-bootstrap-phases-2.png" alt="homelab-part-2-bootstrap/homelab-bootstrap-phases-2" width="800" height="49"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: Cilium Bootstrap
&lt;/h2&gt;

&lt;p&gt;With K3s running but no CNI, pods are stuck in &lt;code&gt;Pending&lt;/code&gt;. Time to install Cilium:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cilium &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--version&lt;/span&gt; &lt;span class="s2"&gt;"1.18.1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; cluster.name&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"homelab"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; cluster.id&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; cni.exclusive&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; hubble.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; hubble.relay.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; hubble.ui.enabled&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;cni.exclusive=false&lt;/code&gt; is important - it allows CNI chaining, which we need later when Istio's CNI joins the party.&lt;/p&gt;

&lt;p&gt;After installation, the script waits for DNS to actually work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;verify_cilium_functionality&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="c"&gt;# Wait for CoreDNS pods&lt;/span&gt;
    kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ready pod &lt;span class="nt"&gt;-l&lt;/span&gt; k8s-app&lt;span class="o"&gt;=&lt;/span&gt;kube-dns &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;120s

    &lt;span class="c"&gt;# Test actual DNS resolution&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;1..30&lt;span class="o"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
        if &lt;/span&gt;kubectl run dns-test &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox:1.36 &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;--&lt;/span&gt; nslookup kubernetes.default.svc.cluster.local&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
            &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DNS is working"&lt;/span&gt;
            &lt;span class="k"&gt;return &lt;/span&gt;0
        &lt;span class="k"&gt;fi
        &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;10
    &lt;span class="k"&gt;done
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"DNS verification failed"&lt;/span&gt;
    &lt;span class="k"&gt;return &lt;/span&gt;1
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This caught so many race conditions. CoreDNS pods can be "Ready" but not actually resolving queries yet.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flyqfub81wnp7fvdw3sy6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Flyqfub81wnp7fvdw3sy6.png" alt="Hubble UI showing network flows between pods" width="800" height="494"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: CRDs and External Secrets
&lt;/h2&gt;

&lt;p&gt;Before ArgoCD can deploy anything, we need:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gateway API CRDs:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.1.0/standard-install.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;External Secrets Operator&lt;/strong&gt; (for pulling secrets from Infisical):&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;external-secrets external-secrets/external-secrets &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-n&lt;/span&gt; external-secrets &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; resources.requests.cpu&lt;span class="o"&gt;=&lt;/span&gt;10m &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; resources.requests.memory&lt;span class="o"&gt;=&lt;/span&gt;32Mi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use Infisical as my secrets backend. The External Secrets Operator syncs secrets into Kubernetes automatically. No more committing secrets to git or manually creating them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 5: ArgoCD
&lt;/h2&gt;

&lt;p&gt;Finally, ArgoCD:&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;argocd argo/argo-cd &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;-n&lt;/span&gt; argocd &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; server.service.type&lt;span class="o"&gt;=&lt;/span&gt;ClusterIP &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; configs.secret.argocdServerAdminPassword&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BCRYPT_PASSWORD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--set&lt;/span&gt; controller.args.appResyncPeriod&lt;span class="o"&gt;=&lt;/span&gt;60
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The admin password comes from Infisical, fetched at the start of the bootstrap:&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;ARGOCD_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;infisical secrets get ARGOCD_ADMIN_PASSWORD &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev &lt;span class="nt"&gt;--projectId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PROJECT_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--plain&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Full Flow
&lt;/h2&gt;

&lt;p&gt;Here's what running the bootstrap looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ ./bootstrap.sh

Phase 0: System Prerequisites
  ✓ Installing chrony for time synchronisation
  ✓ Time sync configured and verified

Phase 1: Validation &amp;amp; Setup
  ✓ Infisical credentials validated
  ✓ ArgoCD password retrieved
  ✓ VM IP detected: 192.168.100.2
  ✓ External connectivity verified

Phase 2: K3s Installation
  ✓ Cleaning up any existing K3s installation
  ✓ Installing K3s (no CNI)
  ✓ Kubeconfig configured for external access

Phase 3: Cilium CNI
  ✓ Installing Cilium v1.18.1
  ✓ Waiting for Cilium to be ready
  ✓ DNS resolution verified

Phase 4: CRDs &amp;amp; Operators
  ✓ Gateway API CRDs installed
  ✓ External Secrets Operator deployed

Phase 5: ArgoCD
  ✓ ArgoCD installed
  ✓ ArgoCD server ready

Bootstrap complete!
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configuration Management
&lt;/h2&gt;

&lt;p&gt;All the cluster-specific values live in a &lt;code&gt;config.env&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CLUSTER_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"homelab"&lt;/span&gt;
&lt;span class="nv"&gt;CLUSTER_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"1"&lt;/span&gt;
&lt;span class="nv"&gt;CLUSTER_CIDR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"10.42.0.0/16"&lt;/span&gt;
&lt;span class="nv"&gt;SERVICE_CIDR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"10.43.0.0/16"&lt;/span&gt;
&lt;span class="nv"&gt;CILIUM_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"1.18.1"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bootstrap script sources this and uses the values throughout. Makes it easy to spin up a second cluster with different settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Time sync is critical&lt;/strong&gt; - Add it to Phase 0 and never think about it again&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase-based scripts save sanity&lt;/strong&gt; - Isolate failures, enable partial re-runs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS verification is not optional&lt;/strong&gt; - Don't assume CoreDNS is ready just because pods are running&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bind to real IPs&lt;/strong&gt; - Localhost doesn't cut it when you're accessing from WSL2&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency matters&lt;/strong&gt; - You will run the script many times&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;The cluster is up, but it's empty. In Part 3, I'll cover how ArgoCD and the app-of-apps pattern deploys everything else - Istio, cert-manager, monitoring, and all the applications.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 2 of a 4-part series on building a homelab Kubernetes setup on Windows.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/homelab-part-2-bootstrap" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/homelab-part-2-bootstrap&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>k3s</category>
      <category>homelab</category>
      <category>cilium</category>
    </item>
    <item>
      <title>The Great WSL Escape - Why My Homelab Runs in a Hyper-V VM</title>
      <dc:creator>Ian Packard</dc:creator>
      <pubDate>Sat, 31 Jan 2026 17:36:39 +0000</pubDate>
      <link>https://dev.to/octasoft-ltd/the-great-wsl-escape-why-my-homelab-runs-in-a-hyper-v-vm-27il</link>
      <guid>https://dev.to/octasoft-ltd/the-great-wsl-escape-why-my-homelab-runs-in-a-hyper-v-vm-27il</guid>
      <description>&lt;p&gt;I wanted to run Kubernetes on my Windows machine. How hard could it be?&lt;/p&gt;

&lt;p&gt;Turns out, harder than expected. This is the story of why my homelab now runs in a Hyper-V VM instead of directly in WSL2, and the networking tricks that make it all work seamlessly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dream: K8s in WSL2
&lt;/h2&gt;

&lt;p&gt;The appeal was obvious. WSL2 is right there, it runs Linux, and I already use it for everything else. Docker Desktop works fine in WSL2. Minikube kind of works. So surely I could run a proper K3s cluster with Cilium as my CNI and Istio for the service mesh?&lt;/p&gt;

&lt;p&gt;Not so much.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CNI Reality Check
&lt;/h2&gt;

&lt;p&gt;Here's where the dream died: CNI plugins.&lt;/p&gt;

&lt;p&gt;WSL2 uses a virtualised network adapter managed by Windows. It's not a real Linux network namespace. When you try to run Cilium (or Calico, or most production-grade CNIs), they expect to do things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create eBPF maps and attach them to network interfaces&lt;/li&gt;
&lt;li&gt;Manipulate iptables and routing tables&lt;/li&gt;
&lt;li&gt;Manage network namespaces for pods&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;WSL2's networking layer doesn't play nicely with any of this. Cilium would partially install, then DNS would break. Or pods would start but couldn't talk to services. Or everything would work until you restarted WSL.&lt;/p&gt;

&lt;p&gt;I spent more hours than I'd like to admit trying different combinations of CNI configurations, WSL kernel parameters, and creative workarounds. Eventually I accepted the truth: WSL2 wasn't designed for this.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pivot: Hyper-V VM
&lt;/h2&gt;

&lt;p&gt;Windows ships with Hyper-V. It's a proper Type 1 hypervisor. And unlike WSL2's abstracted networking, a Hyper-V VM gets real virtual network adapters that behave like actual Linux networking.&lt;/p&gt;

&lt;p&gt;So I created an Ubuntu VM with a static IP on an internal NAT network. The VM could run Cilium properly, eBPF worked, and suddenly Kubernetes networking behaved like it should.&lt;/p&gt;

&lt;p&gt;Problem solved? Almost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The New Problem: How Do I Access This Thing?
&lt;/h2&gt;

&lt;p&gt;Now I had a working Kubernetes cluster, but it was isolated in a Hyper-V VM on a NAT network. From my Windows host I could reach it. But from WSL2 - where I actually do my development work - the VM was unreachable.&lt;/p&gt;

&lt;p&gt;By default, WSL2 runs on its own virtual network (something like 172.x.x.x) that has no route to the Hyper-V internal network (192.168.100.0/24 in my case). I could set up port forwarding, or run a proxy, or configure complex routing rules...&lt;/p&gt;

&lt;p&gt;Or I could use WSL2's mirrored networking mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  WSL2 Mirrored Networking: The Key
&lt;/h2&gt;

&lt;p&gt;This was the game-changer. In your &lt;code&gt;.wslconfig&lt;/code&gt; file:&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;[wsl2]&lt;/span&gt;
&lt;span class="py"&gt;networkingMode&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;mirrored&lt;/span&gt;
&lt;span class="py"&gt;dnsTunneling&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;firewall&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;autoProxy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With mirrored networking, WSL2 shares the Windows host's network interfaces. It can see everything Windows can see - including the Hyper-V internal network. No port forwarding. No routing hacks. Just direct connectivity.&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;# From WSL2, I can now directly reach the VM&lt;/span&gt;
ping 192.168.100.2
kubectl get nodes  &lt;span class="c"&gt;# Works with kubeconfig pointing to VM IP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This requires Windows 11 22H2 or later (or Windows 10 with recent updates), but if you're doing homelab stuff in 2026, you probably have that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Network Architecture
&lt;/h2&gt;

&lt;p&gt;Here's what the final setup looks like:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2gkat52ijk6nsnzp5gof.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2gkat52ijk6nsnzp5gof.png" alt="homelab-part-1-the-great-wsl-escape/homelab-network-topology" width="800" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key points:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;VM has static IP&lt;/strong&gt; (192.168.100.2) - no DHCP surprises&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WSL2 in mirrored mode&lt;/strong&gt; - can directly reach VM without port forwarding&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal NAT network&lt;/strong&gt; - VM has internet access but isn't exposed externally&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;K3s bound to VM IP&lt;/strong&gt; - not localhost, so WSL2 can reach the API server&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setting Up the Hyper-V Network
&lt;/h2&gt;

&lt;p&gt;I created PowerShell scripts to make this repeatable. The NAT setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create internal switch&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;New-VMSwitch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-SwitchName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HomeLab"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-SwitchType&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Internal&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Configure host IP on the switch&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$adapter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Get-NetAdapter&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Where-Object&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="bp"&gt;$_&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-like&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*HomeLab*"&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="n"&gt;New-NetIPAddress&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-IPAddress&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;192.168.100.1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-PrefixLength&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;24&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-InterfaceIndex&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$adapter&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ifIndex&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Create NAT rule for internet access&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;New-NetNat&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"HomeLabNAT"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-InternalIPInterfaceAddressPrefix&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;192.168.100.0/24&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scripts are idempotent - safe to run multiple times if you're iterating on the setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  K3s Configuration for External Access
&lt;/h2&gt;

&lt;p&gt;One gotcha: K3s by default binds to 127.0.0.1 for the API server. That doesn't work when you need to access it from WSL2. The install command needs explicit bind and advertise addresses:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | &lt;span class="nv"&gt;INSTALL_K3S_EXEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"server &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --bind-address=192.168.100.2 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --advertise-address=192.168.100.2 &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --disable=traefik &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --flannel-backend=none &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  --disable-network-policy"&lt;/span&gt; sh -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--flannel-backend=none&lt;/code&gt; is because we're using Cilium instead of K3s's default networking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was It Worth It?
&lt;/h2&gt;

&lt;p&gt;Absolutely. Yes, it's more moving parts than running K8s directly in WSL2 (if that worked). But I now have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A proper Kubernetes cluster with real CNI support&lt;/li&gt;
&lt;li&gt;Cilium with eBPF working correctly&lt;/li&gt;
&lt;li&gt;Istio ambient mode (no sidecars!)&lt;/li&gt;
&lt;li&gt;MetalLB for LoadBalancer services&lt;/li&gt;
&lt;li&gt;Full GitOps with ArgoCD&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And from WSL2, it feels native. &lt;code&gt;kubectl&lt;/code&gt; commands just work. I can port-forward, exec into pods, tail logs - all the normal stuff.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;In Part 2, I'll cover the bootstrap process - how I go from a fresh VM to a working cluster with a single script. Including the time synchronisation nightmare that cost me several hours of debugging.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is Part 1 of a 4-part series on building a homelab Kubernetes setup on Windows.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://wsl-ui.octasoft.co.uk/blog/homelab-part-1-the-great-wsl-escape" rel="noopener noreferrer"&gt;https://wsl-ui.octasoft.co.uk/blog/homelab-part-1-the-great-wsl-escape&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>homelab</category>
      <category>wsl</category>
      <category>hyperv</category>
    </item>
  </channel>
</rss>
