<?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: Kingsley Kanu</title>
    <description>The latest articles on DEV Community by Kingsley Kanu (@captkay).</description>
    <link>https://dev.to/captkay</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1483498%2Fe5e9745c-345d-40cb-956d-4043b6c43944.jpeg</url>
      <title>DEV Community: Kingsley Kanu</title>
      <link>https://dev.to/captkay</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/captkay"/>
    <language>en</language>
    <item>
      <title>The one SSH command Windows doesn't ship</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Thu, 03 Sep 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/captkay/the-one-ssh-command-windows-doesnt-ship-2ehi</link>
      <guid>https://dev.to/captkay/the-one-ssh-command-windows-doesnt-ship-2ehi</guid>
      <description>&lt;p&gt;Every time a fresh machine comes up on my network, the first thing I want is my laptop's key in its &lt;code&gt;authorized_keys&lt;/code&gt; file. On Linux or macOS that is one command you have known for years:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-copy-id root@192.0.2.20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Windows, from PowerShell, that command does not exist. Not "is not on your PATH." Does not exist.&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;ssh-copy-id&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="nx"&gt;The&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;term&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'ssh-copy-id'&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;is&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;recognized&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;as&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;the&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="nx"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cmdlet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="kr"&gt;function&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;script&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;or&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;operable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;program.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Windows has shipped a real OpenSSH client since Windows 10, and it is genuinely good. Here is everything in it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;scp.exe   sftp.exe   ssh.exe   ssh-add.exe   ssh-agent.exe
ssh-keygen.exe   ssh-keyscan.exe   ssh-pkcs11-helper.exe   ssh-sk-helper.exe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine binaries. No &lt;code&gt;ssh-copy-id&lt;/code&gt;. The reason is that &lt;code&gt;ssh-copy-id&lt;/code&gt; was never a binary. It is a POSIX shell script that ships alongside OpenSSH, and Microsoft's port compiles the C programs rather than carrying the shell scripts. So the omission is structural rather than an oversight, which also means it is not going to appear in a future update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do the same job in one line
&lt;/h2&gt;

&lt;p&gt;The script does something simple: read your public key, connect, create the directory, append the key, set the permissions. You can do all of it inline.&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-Content&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;\.ssh\id_ed25519.pub"&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;ssh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;192.0.2.20&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="s2"&gt;"mkdir -p ~/.ssh &amp;amp;&amp;amp; chmod 700 ~/.ssh &amp;amp;&amp;amp; cat &amp;gt;&amp;gt; ~/.ssh/authorized_keys &amp;amp;&amp;amp; chmod 600 ~/.ssh/authorized_keys"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You type the password once, in your own terminal, and never again for that host.&lt;/p&gt;

&lt;p&gt;Two Windows-specific things that trip people up in that line. The path is &lt;code&gt;$env:USERPROFILE\.ssh\&lt;/code&gt;, because &lt;code&gt;~/.ssh&lt;/code&gt; is not a thing PowerShell expands the way a POSIX shell does. And the backtick is PowerShell's line continuation character, not a backslash.&lt;/p&gt;

&lt;p&gt;If you would rather not pipe text between a Windows program and a remote shell at all, this form avoids the whole class of line-ending and encoding questions by passing the key as an argument:&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;$key&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-Content&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;\.ssh\id_ed25519.pub"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Raw&lt;/span&gt;&lt;span class="p"&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="nx"&gt;ssh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;root&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;192.0.2.20&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mkdir -p ~/.ssh &amp;amp;&amp;amp; chmod 700 ~/.ssh &amp;amp;&amp;amp; printf '%s\n' '&lt;/span&gt;&lt;span class="nv"&gt;$key&lt;/span&gt;&lt;span class="s2"&gt;' &amp;gt;&amp;gt; ~/.ssh/authorized_keys &amp;amp;&amp;amp; chmod 600 ~/.ssh/authorized_keys"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the one I use, because it is explicit about the trailing newline instead of hoping the pipeline gets it right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or use the copy you already have
&lt;/h2&gt;

&lt;p&gt;If you have Git for Windows installed, you already have the real script. Git Bash bundles the MSYS2 build of OpenSSH, which includes the shell scripts the Windows port leaves out:&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;$ &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; ssh-copy-id
/usr/bin/ssh-copy-id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So open Git Bash instead of PowerShell and &lt;code&gt;ssh-copy-id root@192.0.2.20&lt;/code&gt; works exactly as it does on Linux. Two different SSH clients live on the same machine, one of which has the command and one of which does not, which is a good thing to know before you spend twenty minutes deciding your installation is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the key lands and login still asks for a password
&lt;/h2&gt;

&lt;p&gt;This is the failure that actually costs time, because everything looks correct. The key is in the file, the file is on the server, and the server still wants a password.&lt;/p&gt;

&lt;p&gt;Almost always it is permissions. &lt;code&gt;sshd&lt;/code&gt; runs with &lt;code&gt;StrictModes&lt;/code&gt; on by default, and it will refuse a key file that anyone other than the owner can write to. That includes the home directory itself, not just &lt;code&gt;.ssh&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod &lt;/span&gt;700 ~/.ssh
&lt;span class="nb"&gt;chmod &lt;/span&gt;600 ~/.ssh/authorized_keys
&lt;span class="nb"&gt;chmod &lt;/span&gt;go-w ~            &lt;span class="c"&gt;# the one people miss&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sshd&lt;/code&gt; will not tell you this on the client side. It just falls through to the next authentication method, which looks identical to the key not being there. The server knows exactly what happened:&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;tail&lt;/span&gt; /var/log/auth.log
Authentication refused: bad ownership or modes &lt;span class="k"&gt;for &lt;/span&gt;directory /root
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;ssh -v&lt;/code&gt; on the client and you will see it offer the key and get nothing back, which at least tells you the problem is on the far end.&lt;/p&gt;

&lt;p&gt;The other common cause is having several keys. The client offers them in order, and if the server's &lt;code&gt;MaxAuthTries&lt;/code&gt; limit is reached before it gets to the right one, you are refused for having too many keys rather than the wrong one. Pin it explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ssh"&gt;&lt;code&gt;&lt;span class="k"&gt;Host&lt;/span&gt; newnode
    &lt;span class="k"&gt;HostName&lt;/span&gt; &lt;span class="m"&gt;192&lt;/span&gt;.0.2.20
    &lt;span class="k"&gt;User&lt;/span&gt; root
    &lt;span class="k"&gt;IdentityFile&lt;/span&gt; ~/.ssh/id_ed25519
    &lt;span class="k"&gt;IdentitiesOnly&lt;/span&gt; &lt;span class="no"&gt;yes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;IdentitiesOnly yes&lt;/code&gt; is the important line. Without it, the client will still offer everything in your agent regardless of what you specified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I bother with any of this
&lt;/h2&gt;

&lt;p&gt;The point is not saving keystrokes. It is that the password gets typed exactly once, locally, by me, on the machine it belongs to. It never goes into a note, a chat window, a script or a config file. After that first connection the credential for that host is a key on my laptop protected by the operating system, and adding a new machine to my network is a thirty second job rather than a decision about where to write a password down.&lt;/p&gt;

&lt;p&gt;That is a small habit that compounds. Every host I have built in the last year got exactly this treatment on its first boot, and I have never had to go looking for one of their passwords, because for most of them I do not remember what it was.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on the addresses
&lt;/h2&gt;

&lt;p&gt;Addresses in this post use the range reserved for documentation under RFC 5737.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/en-us/windows-server/administration/openssh/openssh_overview" rel="noopener noreferrer"&gt;Microsoft OpenSSH for Windows documentation&lt;/a&gt; - what ships and what does not&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://man.openbsd.org/sshd_config#StrictModes" rel="noopener noreferrer"&gt;sshd_config StrictModes&lt;/a&gt; - why permissions silently break key auth&lt;/li&gt;
&lt;li&gt;More homelab write-ups: &lt;a href="https://www.iamkay.eu/blog" rel="noopener noreferrer"&gt;iamkay.eu/blog&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ssh</category>
      <category>windows</category>
      <category>powershell</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>I moved a staging environment off the cloud in one evening</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Mon, 31 Aug 2026 20:00:00 +0000</pubDate>
      <link>https://dev.to/captkay/i-moved-a-staging-environment-off-the-cloud-in-one-evening-54j0</link>
      <guid>https://dev.to/captkay/i-moved-a-staging-environment-off-the-cloud-in-one-evening-54j0</guid>
      <description>&lt;p&gt;Assembyl is a multi-tenant application I work on. Four services out of one monorepo: an API, an admin interface, a tenant-facing web app, and a public landing page. Until recently the staging environment for all of that ran on managed cloud, with a hosted CI service doing build and test on every push, then building containers and deploying them to a serverless runtime, against a paid database tier.&lt;/p&gt;

&lt;p&gt;It worked fine. It also billed every month for an environment nobody touches outside weekday afternoons, while I had a Proxmox cluster in my house with a self-hosted platform layer already running on it, next to my file storage and my git server. The hardware was bought. The electricity was already being spent. Staging was paying rent somewhere else for no reason I could defend.&lt;/p&gt;

&lt;p&gt;The complication was that staging had to stay multi-tenant. Every tenant gets their own subdomain. So the job was never "deploy an app." It was "deploy a routing pattern that survives adding the twentieth tenant without twenty pieces of manual work."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried first, and why it failed
&lt;/h2&gt;

&lt;p&gt;The natural structure is the one everybody reaches for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{tenant}.staging.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One application, one wildcard domain &lt;code&gt;*.staging.example.com&lt;/code&gt;, one mental model. I set up the whole path for it: internal DNS, a catch-all route on the reverse proxy, the tunnel configuration, the certificate request. All of it landed cleanly.&lt;/p&gt;

&lt;p&gt;Then I tested from outside the network and got a TLS handshake failure.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;*.staging.example.com&lt;/code&gt; is a two-level-deep wildcard, because the zone apex is &lt;code&gt;example.com&lt;/code&gt;. Cloudflare's free Universal SSL covers the apex and one level below it. It does not cover a wildcard two labels deep. That needs Advanced Certificate Manager, which is a paid add-on at roughly ten dollars a month.&lt;/p&gt;

&lt;p&gt;I was doing this migration specifically to stop paying a monthly bill. Trading one subscription for a smaller subscription is not a migration, it is a discount. So that was out.&lt;/p&gt;

&lt;p&gt;The other option I considered seriously was dropping public access entirely and serving staging over a private network overlay, with the certificate issued through a DNS challenge and no public ingress at all. That works, and it costs nothing. It also means every person who wants to look at staging has to install a VPN client first. For an engineering-only environment that is a fine trade. For something where the point is that a colleague or a prospective customer can open a link in whatever browser they have, it breaks the use case entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was to flatten the namespace
&lt;/h2&gt;

&lt;p&gt;Keep the wildcard one level deep by moving the tenant name into the label rather than in front of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{tenant}-as.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-as&lt;/code&gt; suffix acts as the namespace. Same &lt;code&gt;*.example.com&lt;/code&gt; certificate that Universal SSL already issues for free, covering every tenant that will ever exist. No paid tier, no VPN requirement, no per-tenant certificate.&lt;/p&gt;

&lt;p&gt;Two concessions came with it, and both are worth stating plainly rather than glossing.&lt;/p&gt;

&lt;p&gt;Each tenant needs its own DNS record. Wildcard DNS records have to have the wildcard at the start of a label, so &lt;code&gt;*-as.example.com&lt;/code&gt; is not a thing you can create. That is a per-tenant API call, which is automatable, but it is not zero.&lt;/p&gt;

&lt;p&gt;And tenant names become visible in DNS. Anyone enumerating the zone's records can see who the tenants are. For a staging environment full of test accounts that is acceptable. For production it would not be, and production would need a different pattern.&lt;/p&gt;

&lt;p&gt;In exchange: one certificate, one routing rule, and adding a tenant never touches the proxy configuration at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  One routing rule for every tenant
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;routers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tenants&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HostRegexp(`^[a-z0-9][a-z0-9-]*-as&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;.example&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="s"&gt;.com$`)"&lt;/span&gt;
      &lt;span class="na"&gt;entryPoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;websecure&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;certResolver&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;letsencrypt&lt;/span&gt;
        &lt;span class="na"&gt;domains&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;main&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.example.com"&lt;/span&gt;
      &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;tenants-svc&lt;/span&gt;
      &lt;span class="na"&gt;middlewares&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;security-headers@file&lt;/span&gt;

  &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tenants-svc&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;loadBalancer&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://192.0.2.80:80"&lt;/span&gt;
        &lt;span class="na"&gt;passHostHeader&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every tenant name matches that regex. Every request lands on the platform proxy, which routes by Host header to the right container. &lt;code&gt;passHostHeader&lt;/code&gt; is the load-bearing line: drop it and every tenant arrives at the application looking like the same host, which in a multi-tenant app is a genuinely bad afternoon.&lt;/p&gt;

&lt;h3&gt;
  
  
  Warm the certificate before anyone arrives
&lt;/h3&gt;

&lt;p&gt;Issuing a wildcard certificate through a DNS challenge takes about thirty seconds. If the first request that triggers issuance is a real user's, that user gets thirty seconds of nothing, or a timeout. So trigger it yourself with a request for a name that does not exist yet:&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;-sk&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Host: prewarm-as.example.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://192.0.2.10/ &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"HTTP %{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That matches the regex, which triggers the certificate request, which issues and caches the wildcard. Every subsequent tenant deploy has zero wait. It costs one throwaway request and it is the difference between a launch that looks instant and one that looks broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the migration actually looked like
&lt;/h2&gt;

&lt;p&gt;Four applications on the self-hosted platform layer, each pointing at a different directory of the same monorepo, each with its own domain and its own build. All four went from "create application" to serving in about fifty minutes. The CI moved to a self-hosted runner on the same cluster, which had already replaced the hosted CI service earlier that week.&lt;/p&gt;

&lt;p&gt;New recurring cost for the staging environment: nothing. The hardware was already there, already powered, already backed up nightly as part of everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this holds up
&lt;/h2&gt;

&lt;p&gt;The free tier of a CDN is genuinely the right scale for a staging environment. Tunnelled ingress plus free certificates handles public access with no port forwarding and no exposed origin. The one-level wildcard limit is only a problem if you insist on fighting it. Flatten the namespace and it stops existing.&lt;/p&gt;

&lt;p&gt;A regex-matched catch-all route scales to any number of tenants in one block of configuration. The proxy is the piece you least want to be editing under time pressure, and this pattern means you never edit it after the first time.&lt;/p&gt;

&lt;p&gt;Running the platform layer in its own container on the cluster keeps it from becoming a shared fate with everything else. It gets its own backup unit, its own resource ceiling, its own restart. When it needs attention, nothing else is affected.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would do differently
&lt;/h2&gt;

&lt;p&gt;Use the flat pattern from the beginning instead of arriving at it after ninety minutes of debugging a TLS failure. The certificate limitation is documented; I just did not go looking until something broke.&lt;/p&gt;

&lt;p&gt;Wire the deploy webhook before the first deploy rather than after. I spent the first stretch clicking Deploy by hand, which is fine once and irritating by the fifth time.&lt;/p&gt;

&lt;p&gt;Add health check endpoints to the services before deploying rather than afterwards. Two of the four apps sat in an unknown health state for days purely because nothing was answering a probe, and an unknown state is worse than a red one because it is easy to ignore.&lt;/p&gt;

&lt;p&gt;The one thing I deliberately have not moved is the production database, which is still on a managed tier. The homelab is not on battery backup yet, and a primary database is the wrong place to find out how your house handles a power cut. Staging can afford to learn that lesson. Production cannot.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on the addresses
&lt;/h2&gt;

&lt;p&gt;Domains and addresses in this post are documentation placeholders on reserved ranges (RFC 5737 and RFC 2606). The architecture is exactly what runs; the specific names are stand-ins.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://coolify.io/docs" rel="noopener noreferrer"&gt;Coolify documentation&lt;/a&gt; - the self-hosted platform layer&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://doc.traefik.io/traefik/routing/routers/#rule" rel="noopener noreferrer"&gt;Traefik HostRegexp matcher&lt;/a&gt; - the catch-all rule&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/" rel="noopener noreferrer"&gt;Cloudflare Tunnel&lt;/a&gt; - ingress with no open ports&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://letsencrypt.org/docs/rate-limits/" rel="noopener noreferrer"&gt;Let's Encrypt rate limits&lt;/a&gt; - pre-warming once is well inside them&lt;/li&gt;
&lt;li&gt;More homelab write-ups: &lt;a href="https://www.iamkay.eu/blog" rel="noopener noreferrer"&gt;iamkay.eu/blog&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>selfhosted</category>
      <category>coolify</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Two machines answered to the same address</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Thu, 27 Aug 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/captkay/two-machines-answered-to-the-same-address-2ehb</link>
      <guid>https://dev.to/captkay/two-machines-answered-to-the-same-address-2ehb</guid>
      <description>&lt;p&gt;I was mid-way through bringing a new server onto the network when I picked an address for it. The next free one in the range, or so I thought. I said the number out loud while typing it, the way you do, and something about it felt familiar.&lt;/p&gt;

&lt;p&gt;It was the password vault's address. It had been the password vault's address for two weeks. I had checked the machines on the network, seen nothing at that number, and moved on, because the vault was a container running on a different host and my check had only ever been going to find things on the host I was standing on.&lt;/p&gt;

&lt;p&gt;That one I caught before it did anything. The fix was to move the vault to the next address up and let the new server keep the one I had already configured at the switch: stop the container, edit its address, update the reverse proxy backend and the internal DNS record, start it again. Ten minutes.&lt;/p&gt;

&lt;p&gt;A few weeks later I did the same thing again and did not catch it, and that is the interesting half of this story.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that does not check
&lt;/h2&gt;

&lt;p&gt;On a Proxmox cluster the obvious way to see what is running is to ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pct list
qm list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both of those tell you about guests on the node you are logged into. They say nothing about the guests on the other nodes. If you have three hosts and you run this on one of them, you are looking at roughly a third of your estate and it does not look partial. It looks like a complete list, because it is a complete list of something.&lt;/p&gt;

&lt;p&gt;A ping test is no better, and is worse in one specific way. It tells you whether something answers right now. A container that is stopped, or a machine that is powered off, still holds its claim on that address in its configuration, and will take it back the moment it starts.&lt;/p&gt;

&lt;p&gt;So the second time, I provisioned a new container on the address my checks said was free. It was not free. A container on a different node had held it since a build I had left half-finished weeks earlier and had stopped thinking about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a duplicate address actually looks like
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalising, because the symptoms do not spell out "duplicate address" at all.&lt;/p&gt;

&lt;p&gt;Ping worked from every node, consistently. Two machines both received the address resolution request, both answered it, and whichever reply arrived first won that particular exchange. Ping does not care which one replies, so it looked completely healthy from every angle I tested.&lt;/p&gt;

&lt;p&gt;Then the actual service failed, and it failed differently depending on where I tested from.&lt;/p&gt;

&lt;p&gt;From most machines, connecting to the application port returned connection refused. Those machines had cached the other container's hardware address, and that container has nothing listening on that port.&lt;/p&gt;

&lt;p&gt;From one machine, everything worked perfectly. It happened to have cached the correct hardware address, so it reached the right container every time. That single working case cost me a good twenty minutes, because a service that works from exactly one place looks like a firewall rule or a routing problem, not an addressing problem.&lt;/p&gt;

&lt;p&gt;And the reverse proxy, which sat on a node that had cached the wrong address, served an unbroken run of 502s while I stood on the one node where curl was returning 200.&lt;/p&gt;

&lt;p&gt;Flushing the address cache does not fix this, which is the tell. The wrong machine is still there, still configured with that address, still answering resolution requests. Clear the cache and it repopulates, sometimes correctly, sometimes not. Anything that gets better on one machine and stays broken on another, and shuffles when you clear a cache, is an addressing conflict until proven otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that actually works
&lt;/h2&gt;

&lt;p&gt;Every guest on a Proxmox cluster has its config file replicated to every node through the cluster filesystem. So the authoritative question is not "what is running here" but "what does the cluster believe it has handed out."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhE&lt;/span&gt; &lt;span class="s2"&gt;"ip=192&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;2&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /etc/pve/qemu-server/ /etc/pve/lxc/ &lt;span class="se"&gt;\&lt;/span&gt;
  /etc/pve/nodes/&lt;span class="k"&gt;*&lt;/span&gt;/qemu-server/ /etc/pve/nodes/&lt;span class="k"&gt;*&lt;/span&gt;/lxc/ 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s2"&gt;"192&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;2&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;[0-9]+"&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-k4&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns every address claimed by every guest on every node, running or stopped, in numeric order. Gaps in the output are genuinely free. It takes about a second and it is the only check in this post that is actually authoritative.&lt;/p&gt;

&lt;p&gt;Ping the candidate afterwards as a belt-and-braces step, by all means. But the config grep is the source of truth, and the ping is not, because a stopped guest is invisible to ping and still owns its number.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape of the mistake
&lt;/h2&gt;

&lt;p&gt;Both times, the failure was the same one: I ran a check whose scope was narrower than the question I was asking.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pct list&lt;/code&gt; answers "what is on this node." I was asking "what is on this cluster." Ping answers "what is awake at this address." I was asking "what is assigned to this address." Neither tool was wrong. Both gave complete, accurate answers to questions I had not meant to ask.&lt;/p&gt;

&lt;p&gt;That is a much easier failure to walk into than a tool that gives you a wrong answer, because a wrong answer eventually contradicts something. A correctly-scoped answer to the wrong question agrees with everything, right up until it does not.&lt;/p&gt;

&lt;p&gt;The habit I have taken from it is to say the question out loud before choosing the command, specifically the scope of it. Not "is this address free" but "is this address free anywhere in the cluster, including on things that are currently switched off." Phrased that way, &lt;code&gt;pct list&lt;/code&gt; obviously does not answer it, and the right command is the one you reach for instead.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on the addresses
&lt;/h2&gt;

&lt;p&gt;Addresses in this post use the range reserved for documentation under RFC 5737. The incidents and the behaviour are real; the numbers are stand-ins.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pve.proxmox.com/wiki/Proxmox_Cluster_File_System_(pmxcfs)" rel="noopener noreferrer"&gt;Proxmox cluster file system&lt;/a&gt; - why every node holds every config&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.rfc-editor.org/rfc/rfc826" rel="noopener noreferrer"&gt;RFC 826, Address Resolution Protocol&lt;/a&gt; - why the first reply wins&lt;/li&gt;
&lt;li&gt;More homelab write-ups: &lt;a href="https://www.iamkay.eu/blog" rel="noopener noreferrer"&gt;iamkay.eu/blog&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>networking</category>
      <category>proxmox</category>
      <category>arp</category>
      <category>troubleshooting</category>
    </item>
    <item>
      <title>I deleted the original before checking the copy had landed</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Mon, 24 Aug 2026 20:00:00 +0000</pubDate>
      <link>https://dev.to/captkay/i-deleted-the-original-before-checking-the-copy-had-landed-p5o</link>
      <guid>https://dev.to/captkay/i-deleted-the-original-before-checking-the-copy-had-landed-p5o</guid>
      <description>&lt;p&gt;It was around half past midnight. I was renaming a Proxmox node, the last real step in a five hour session that had already gone well. Two commands left. I ran them in the order they appeared in my notes.&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;cp&lt;/span&gt; &lt;span class="nt"&gt;-av&lt;/span&gt; /etc/pve/nodes/oldname/qemu-server/. /etc/pve/nodes/newname/qemu-server/
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /etc/pve/nodes/oldname/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The copy printed nothing. The delete printed nothing. Both of those are normal outputs for a successful run, so I moved on to the next thing.&lt;/p&gt;

&lt;p&gt;The copy had failed. The destination subdirectories did not exist yet, because the daemon that owns that filesystem had created the parent directory but had not finished creating the children. A copy into a path that is not there does not error loudly. It reports that it did nothing, which on a terminal at midnight is indistinguishable from reporting that it did everything.&lt;/p&gt;

&lt;p&gt;So the second command deleted the only canonical copy of the configuration for twelve running production services.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually gone
&lt;/h2&gt;

&lt;p&gt;Every guest on that host was defined by a small text file: which disks it mounts, which network bridge it attaches to, how much memory it gets, which VLAN it sits on, what its boot order is. Twelve of those, covering the family file storage, the password vault, the reverse proxy, both DNS resolvers, the monitoring stack, the git server.&lt;/p&gt;

&lt;p&gt;Not the data. The data was untouched, sitting on its volumes exactly where it had always been. What I had destroyed was the description of how any of it fits together. Which is arguably worse, because data you can point at. A container with no config is not a container, it is an unlabelled disk image.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing went down
&lt;/h2&gt;

&lt;p&gt;Here is the part that saved me, and it is worth understanding properly rather than treating it as luck.&lt;/p&gt;

&lt;p&gt;A running container or virtual machine does not consult its config file while it runs. The management layer reads that file once, at start, uses it to build the runtime, and then the running process carries on from what it already has in memory. Nothing re-reads the directory during normal operation. The management daemon only rebuilds its picture of the world from those files on a cold start.&lt;/p&gt;

&lt;p&gt;So for as long as nothing restarted, every one of those twelve services kept serving traffic as though nothing had happened. Nextcloud carried on syncing, the vault carried on unlocking, and DNS never missed a query. My family noticed nothing, that night or ever.&lt;/p&gt;

&lt;p&gt;That is a genuine forgiveness window, and it is a real property of how the system is built rather than a fluke. It is also the most dangerous kind of safety net, because it is invisible. Nothing tells you that you are inside it. If I had rebooted that host, or if the power had cut, I would have come back up to twelve stopped services and no record of how any of them were configured. The window was open. It was not going to announce when it closed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The recovery
&lt;/h2&gt;

&lt;p&gt;I had taken a full copy of the whole configuration tree to a plain directory on local disk about thirty minutes earlier, as the first step of the rename. Not a snapshot inside the same filesystem I was about to break, an ordinary directory somewhere else that I could read with &lt;code&gt;cat&lt;/code&gt; when everything clever had stopped working.&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;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/pve /root/etc-pve.backup-pre-rename-20260617-001153
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recovery was copying the config files back into the correct node directory, once it existed properly, and restarting the management services. Around four minutes of work. All twelve guests reappeared in the interface still running, with the same process IDs they had held before the rename started, because they had never stopped.&lt;/p&gt;

&lt;p&gt;That backup was the only reason this is a story about a mistake rather than a story about a rebuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually went wrong
&lt;/h2&gt;

&lt;p&gt;It is tempting to file this under "be careful with &lt;code&gt;rm -rf&lt;/code&gt;" and move on. That is not the lesson, and treating it as the lesson is how you make the same mistake again with a different command.&lt;/p&gt;

&lt;p&gt;The real error is that I turned an operation with built-in safety into two operations without it. &lt;code&gt;mv&lt;/code&gt; is atomic. It either moves the thing or it does not, and it will not leave you in a state where the source is gone and the destination is empty. I could not use &lt;code&gt;mv&lt;/code&gt; here, because the daemon had already created the destination and &lt;code&gt;mv&lt;/code&gt; refuses to move onto a non-empty directory. So I reconstructed &lt;code&gt;mv&lt;/code&gt; by hand out of &lt;code&gt;cp&lt;/code&gt; and &lt;code&gt;rm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The moment you do that, you have taken on a job the kernel was doing for you: checking that the first half succeeded before running the second half. I did not do that job. I just typed both halves.&lt;/p&gt;

&lt;p&gt;The second error is that I read silence as success. &lt;code&gt;cp -av&lt;/code&gt; is verbose, that is what the &lt;code&gt;v&lt;/code&gt; is for, and it printed no filenames because it copied no files. I saw an absence of errors where I should have seen an absence of output. Those are different things, and at midnight they look identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I actually wrote down
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Verify the destination before destroying the source. Every destructive command needs a gate in front of it, and the gate has to check for presence, not for the absence of an error.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In practice, for this specific operation:&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;cp&lt;/span&gt; &lt;span class="nt"&gt;-av&lt;/span&gt; /etc/pve/nodes/oldname/lxc/. /etc/pve/nodes/newname/lxc/

&lt;span class="c"&gt;# the gate&lt;/span&gt;
&lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /etc/pve/nodes/oldname/lxc/ | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;dst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /etc/pve/nodes/newname/lxc/ | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"source: &lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;  destination: &lt;/span&gt;&lt;span class="nv"&gt;$dst&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dst&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &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="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"MISMATCH, stopping"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four extra lines. It would have caught this instantly, because the destination count would have been zero and the source count twelve.&lt;/p&gt;

&lt;p&gt;There is also a simpler answer that I have adopted since, which is to not run the delete at all. Nothing required me to remove the old directory. It was a handful of small text files taking up a trivial amount of space on a machine with hundreds of gigabytes free. I deleted it because leaving it there felt messy.&lt;/p&gt;

&lt;p&gt;That is the part that stays with me. The only genuinely destructive command in a thirty minute procedure was the one that served no purpose. It was not in service of the goal. It was housekeeping. And housekeeping is a bad reason to run &lt;code&gt;rm -rf&lt;/code&gt; against the only copy of anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would tell someone about to do this
&lt;/h2&gt;

&lt;p&gt;Take the backup to somewhere outside the system you are about to modify, and take it first, before you feel like you need it.&lt;/p&gt;

&lt;p&gt;Put a verification gate between any copy and any delete that depends on it, and make the gate assert that files are present rather than assert that nothing errored.&lt;/p&gt;

&lt;p&gt;Do the destructive work at the start of an evening, not the end. This was a sequencing failure, and sequencing is the first thing that degrades when you are tired and the checklist is nearly finished.&lt;/p&gt;

&lt;p&gt;And if a step exists only because the leftovers look untidy, consider not doing it.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on the names
&lt;/h2&gt;

&lt;p&gt;Hostnames and paths in this post use documentation placeholders. The incident, the mechanism and the recovery are exactly as they happened.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://www.iamkay.eu/blog/pve-node-rename" rel="noopener noreferrer"&gt;Renaming a live Proxmox node&lt;/a&gt; - the procedure this happened inside&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pve.proxmox.com/wiki/Proxmox_Cluster_File_System_(pmxcfs)" rel="noopener noreferrer"&gt;Proxmox pmxcfs documentation&lt;/a&gt; - why the destination gets created for you&lt;/li&gt;
&lt;li&gt;More homelab write-ups: &lt;a href="https://www.iamkay.eu/blog" rel="noopener noreferrer"&gt;iamkay.eu/blog&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>proxmox</category>
      <category>incidentresponse</category>
      <category>sysadmin</category>
      <category>homelab</category>
    </item>
    <item>
      <title>The packets left. Nothing came back.</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Thu, 20 Aug 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/captkay/the-packets-left-nothing-came-back-1dd8</link>
      <guid>https://dev.to/captkay/the-packets-left-nothing-came-back-1dd8</guid>
      <description>&lt;p&gt;I had just finished renaming a Proxmox node. Every guest was still running, the cluster was quorate, the web UI certificate had been regenerated under the new name. I ran the last check, pinged my router from the host, got four clean replies, and went to open the web UI from my laptop.&lt;/p&gt;

&lt;p&gt;Nothing. No certificate warning, no timeout page, just a connection that hung and eventually gave up.&lt;/p&gt;

&lt;p&gt;The containers on that host were all still reachable. Nextcloud loaded fine, so did the password vault and the dashboards. Only the host underneath them had gone dark, and only from the outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom is the confusing part
&lt;/h2&gt;

&lt;p&gt;Outbound worked perfectly. From the host I could ping anything on the network, resolve DNS, reach the internet, SSH out to another machine. From anywhere else, the host was a black hole. SSH hung, the web UI hung, ping got no reply.&lt;/p&gt;

&lt;p&gt;That asymmetry is what sends you looking in the wrong place. Networking problems are usually symmetric. A bad route, a dead link, a wrong VLAN tag, a bridge in the wrong state, all of those break traffic in both directions. So I checked every one of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip &lt;span class="nt"&gt;-br&lt;/span&gt; addr        &lt;span class="c"&gt;# correct addresses on the right interfaces&lt;/span&gt;
ip route           &lt;span class="c"&gt;# default route present and correct&lt;/span&gt;
bridge &lt;span class="nb"&gt;link&lt;/span&gt;        &lt;span class="c"&gt;# bridge ports up, VLANs correct&lt;/span&gt;
ping 192.0.2.1     &lt;span class="c"&gt;# replies fine&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything healthy. Meanwhile a &lt;code&gt;tcpdump&lt;/code&gt; on the host showed inbound SYN packets arriving from my laptop. They were reaching the machine. Nothing was going back.&lt;/p&gt;

&lt;p&gt;At that point the answer is almost always a firewall, and I still nearly missed it, because I had not touched the firewall. I had renamed a host.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cause
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pve-firewall status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Status: enabled/running (pending changes)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That parenthetical is the whole story.&lt;/p&gt;

&lt;p&gt;Proxmox keeps firewall configuration in two layers. There is the on-disk config, a cluster-wide &lt;code&gt;cluster.fw&lt;/code&gt; plus a per-node &lt;code&gt;host.fw&lt;/code&gt; living under &lt;code&gt;/etc/pve/nodes/&amp;lt;hostname&amp;gt;/&lt;/code&gt;. And there are the actual running kernel rules, which the &lt;code&gt;pve-firewall&lt;/code&gt; daemon generates from that config and pushes into iptables.&lt;/p&gt;

&lt;p&gt;Rename the host and the per-node config moves to a new path along with the rest of the node's state. The daemon notices that what is on disk no longer matches what it pushed into the kernel, and it flags the drift as pending changes. What it does not do is apply them. The kernel keeps enforcing the rules it was given before the rename, and those rules scope the management access list to a node name that no longer exists.&lt;/p&gt;

&lt;p&gt;Outbound stayed up because the default outbound policy is accept and does not depend on per-node scoping. Inbound died because the accept rule that should have matched my laptop was keyed to the old name.&lt;/p&gt;

&lt;p&gt;The guests kept working for a completely different reason: they reach the network through the bridge, not through the host's own IP stack, so the host firewall's input chain was never in their path. That is why the box looked half alive. Half of it was.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pve-firewall restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under a second. Reachability came back before I had finished reading the output.&lt;/p&gt;

&lt;p&gt;Note that it is &lt;code&gt;restart&lt;/code&gt;, not &lt;code&gt;reload&lt;/code&gt;. There is no &lt;code&gt;reload&lt;/code&gt; subcommand on Proxmox 8, which is a small thing that will waste a minute if you are guessing at syntax while locked out of a box.&lt;/p&gt;

&lt;p&gt;And you want to be on the console or an out-of-band path when you run it, because if you are somehow still connected over SSH through a rule that happens to be working, restarting the firewall is the exact operation that could drop you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bites so hard
&lt;/h2&gt;

&lt;p&gt;The failure has three properties that make it worse than an ordinary outage.&lt;/p&gt;

&lt;p&gt;It is silent. Nothing logs an error. &lt;code&gt;pve-firewall status&lt;/code&gt; reports itself as enabled and running, which is true, and buries the important word in brackets after it. If you check services with a script that greps for &lt;code&gt;running&lt;/code&gt;, this passes.&lt;/p&gt;

&lt;p&gt;It is asymmetric, so it does not look like a firewall. Your instinct on "I can get out but not in" is to look at routing, NAT and the upstream, in roughly that order. A host firewall that lets everything out is not where most people start.&lt;/p&gt;

&lt;p&gt;And it is delayed. The rename appears to succeed. Every check you run on the host itself comes back clean, because every check you run on the host itself is outbound. You only discover the problem when you go back to your own machine, which is usually after you have declared the work finished and closed the console.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I changed
&lt;/h2&gt;

&lt;p&gt;The rename procedure in my own runbook now ends with &lt;code&gt;pve-firewall restart&lt;/code&gt; as a numbered step, not a footnote, followed by a verification that has to be run from a different machine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# on the renamed node&lt;/span&gt;
pve-firewall status          &lt;span class="c"&gt;# must not say "pending changes"&lt;/span&gt;

&lt;span class="c"&gt;# from any OTHER host on the network&lt;/span&gt;
ping &lt;span class="nt"&gt;-c&lt;/span&gt; 2 &amp;lt;node-ip&amp;gt;
curl &lt;span class="nt"&gt;-sk&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; https://&amp;lt;node-ip&amp;gt;:8006/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule I actually took from it is broader than the command. Any procedure that changes a machine's identity should end with a check performed from somewhere that is not that machine. Everything I ran on the host told me the host was fine. The host was not fine. It just had no way to tell me, because telling me was the thing that was broken.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on the addresses
&lt;/h2&gt;

&lt;p&gt;Addresses in this post use the ranges reserved for documentation under RFC 5737. The behaviour is real; the specific addresses are stand-ins so nothing here maps to a live target.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pve.proxmox.com/wiki/Firewall" rel="noopener noreferrer"&gt;Proxmox VE firewall documentation&lt;/a&gt; - the config layers and how they are applied&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.iamkay.eu/blog/pve-node-rename" rel="noopener noreferrer"&gt;Renaming a live Proxmox node&lt;/a&gt; - the procedure this gotcha belongs to&lt;/li&gt;
&lt;li&gt;More homelab write-ups: &lt;a href="https://www.iamkay.eu/blog" rel="noopener noreferrer"&gt;iamkay.eu/blog&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>proxmox</category>
      <category>firewall</category>
      <category>networking</category>
      <category>troubleshooting</category>
    </item>
    <item>
      <title>I renamed a live Proxmox node and twelve guests never noticed</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Mon, 17 Aug 2026 20:00:00 +0000</pubDate>
      <link>https://dev.to/captkay/i-renamed-a-live-proxmox-node-and-twelve-guests-never-noticed-5ce1</link>
      <guid>https://dev.to/captkay/i-renamed-a-live-proxmox-node-and-twelve-guests-never-noticed-5ce1</guid>
      <description>&lt;p&gt;The host had the wrong name. I had given it the good name, the one I wanted for the whole cluster, back when it was the only machine I owned. Then I bought two more machines, and the good name was sitting on the box least likely to survive the next two years.&lt;/p&gt;

&lt;p&gt;So the node needed a new name, and it needed it while twelve production guests were running on it. Nextcloud, the password vault, the reverse proxy, the DNS resolvers, the monitoring stack. Things my family notices when they stop.&lt;/p&gt;

&lt;p&gt;Renaming a Linux box is one command. Renaming a Proxmox node is not, because Proxmox keeps a per-node directory of state keyed by hostname, inside a filesystem you do not fully control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Proxmox stores cluster configuration in &lt;code&gt;/etc/pve&lt;/code&gt;, which looks like a normal directory and is not one. It is a FUSE mount backed by &lt;code&gt;pmxcfs&lt;/code&gt;, a small replicated database that syncs across every node in the cluster. Under it, &lt;code&gt;/etc/pve/nodes/&amp;lt;hostname&amp;gt;/&lt;/code&gt; holds everything that belongs to that specific machine: the guest configs in &lt;code&gt;qemu-server/&lt;/code&gt; and &lt;code&gt;lxc/&lt;/code&gt;, the per-node firewall rules, the SSL certificate.&lt;/p&gt;

&lt;p&gt;Change the hostname and that directory path no longer matches the machine. The guest configs are still on disk, but they are sitting under a name the node no longer answers to. Proxmox will happily boot into that state and show you an empty node with no VMs on it, which is a memorable thing to see at half past midnight.&lt;/p&gt;

&lt;p&gt;The instinct is to move the directory. That instinct is wrong, and it is wrong in a way that fails safe the first time and unsafe the second.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried, and what failed
&lt;/h2&gt;

&lt;p&gt;My first move was the obvious one:&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;mv&lt;/span&gt; /etc/pve/nodes/oldname /etc/pve/nodes/newname
&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;mv&lt;/span&gt;: cannot move &lt;span class="s1"&gt;'/etc/pve/nodes/oldname'&lt;/span&gt; to &lt;span class="s1"&gt;'/etc/pve/nodes/newname'&lt;/span&gt;: Directory not empty
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The target already existed. The moment &lt;code&gt;pmxcfs&lt;/code&gt; saw the new hostname, it created the new node directory itself, complete with empty &lt;code&gt;qemu-server/&lt;/code&gt; and &lt;code&gt;lxc/&lt;/code&gt; subdirectories. You are not moving into empty space. The daemon got there first.&lt;/p&gt;

&lt;p&gt;Fine, so copy instead:&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;cp&lt;/span&gt; &lt;span class="nt"&gt;-av&lt;/span&gt; /etc/pve/nodes/oldname/qemu-server/. /etc/pve/nodes/newname/qemu-server/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is where it got interesting. Run that too early, in the seconds before &lt;code&gt;pmxcfs&lt;/code&gt; has created the subdirectories, and it does not error in a way that stops you. It reports nothing copied and returns. If you are working through a checklist at speed, "no output" reads exactly like "done."&lt;/p&gt;

&lt;p&gt;I then did the thing I am going to write a whole separate post about, which is that I deleted the source directory before confirming the copy had landed. Twelve guest configs, gone from the only canonical copy. I will spare you the details here; the short version is that a pre-rename backup saved me and production never actually went down.&lt;/p&gt;

&lt;p&gt;The reason production survived is worth understanding, because it is the mechanism that makes this whole operation safe. A running LXC or QEMU guest does not read &lt;code&gt;/etc/pve&lt;/code&gt; continuously. It reads its config once, at start, and then runs from what it already holds. &lt;code&gt;pve-manager&lt;/code&gt; re-reads the directory on a cold start. So for as long as nothing restarts, the configs can be missing entirely and every workload keeps serving. That is a real forgiveness window, and it is the only reason a rename on a live node is a reasonable thing to attempt.&lt;/p&gt;

&lt;p&gt;It is forgiveness, not permission. A power cut during that window and I would have had twelve running services and no idea how any of them were configured.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Here is the sequence that works, with the verification gates that stop the failure modes above. Take a backup of the whole &lt;code&gt;/etc/pve&lt;/code&gt; tree to somewhere outside &lt;code&gt;/etc/pve&lt;/code&gt; first. Not to a snapshot, to a plain directory on local disk you can read with &lt;code&gt;cat&lt;/code&gt; when everything else is broken.&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;cp&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/pve /root/etc-pve.backup-pre-rename-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d-%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Set the new name in both places&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/oldname/newname/'&lt;/span&gt; /etc/hostname
&lt;span class="c"&gt;# keep the old name as an alias in /etc/hosts through the transition&lt;/span&gt;
hostnamectl set-hostname newname

&lt;span class="c"&gt;# 2. WAIT. Let pmxcfs notice and build the new directory itself.&lt;/span&gt;
&lt;span class="c"&gt;#    Do not mv. Do not mkdir. Just watch for it to appear.&lt;/span&gt;
&lt;span class="k"&gt;until&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; /etc/pve/nodes/newname/qemu-server &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /etc/pve/nodes/newname/

&lt;span class="c"&gt;# 3. Copy the state across&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-av&lt;/span&gt; /etc/pve/nodes/oldname/qemu-server/. /etc/pve/nodes/newname/qemu-server/
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-av&lt;/span&gt; /etc/pve/nodes/oldname/lxc/.         /etc/pve/nodes/newname/lxc/
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-av&lt;/span&gt; /etc/pve/nodes/oldname/host.fw       /etc/pve/nodes/newname/ 2&amp;gt;/dev/null

&lt;span class="c"&gt;# 4. VERIFY THE COUNTS MATCH before you delete anything&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /etc/pve/nodes/oldname/qemu-server/ | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /etc/pve/nodes/newname/qemu-server/ | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /etc/pve/nodes/oldname/lxc/ | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; /etc/pve/nodes/newname/lxc/ | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# 5. Only when those numbers agree, and honestly you can just leave the old&lt;/span&gt;
&lt;span class="c"&gt;#    directory in place forever, it costs a few kilobytes&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /etc/pve/nodes/oldname/

&lt;span class="c"&gt;# 6. Restart the services that cache the node identity&lt;/span&gt;
systemctl restart pve-cluster
systemctl restart pvedaemon pveproxy pvestatd

&lt;span class="c"&gt;# 7. Regenerate the web UI certificate under the new name&lt;/span&gt;
pvecm updatecerts &lt;span class="nt"&gt;-f&lt;/span&gt;

&lt;span class="c"&gt;# 8. The step everyone misses&lt;/span&gt;
pve-firewall restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one that cost me the second half of the night. Proxmox marks the firewall as having pending changes after a hostname change and does not apply them. The running kernel rules still scope to the old node name, so the host can reach outward while nothing can reach in. It is a silent, asymmetric loss of connectivity and it looks nothing like a firewall problem. That one has its own write-up on Thursday.&lt;/p&gt;

&lt;p&gt;Verify before you call it done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pve-firewall status     &lt;span class="c"&gt;# must NOT say "pending changes"&lt;/span&gt;
pvecm status            &lt;span class="c"&gt;# quorum intact, node listed under the new name&lt;/span&gt;
qm list &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pct list     &lt;span class="c"&gt;# every guest present and still running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And ping the node from another machine on the network. Not from itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works
&lt;/h2&gt;

&lt;p&gt;The whole procedure comes down to one idea: &lt;code&gt;pmxcfs&lt;/code&gt; owns that directory tree, and you are a guest in it. Every step that fights the daemon fails, and every step that waits for the daemon works.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mv&lt;/code&gt; fails because the daemon has already created the destination. &lt;code&gt;mkdir&lt;/code&gt; is unnecessary for the same reason. The copy has to wait because the subdirectories appear a beat after the parent does, and a copy into a path that does not exist yet is not an error, it is just nothing. The verification gate exists because "nothing happened" and "everything happened" produce the same silence on the terminal.&lt;/p&gt;

&lt;p&gt;The service restarts are ordered deliberately. &lt;code&gt;pve-cluster&lt;/code&gt; owns &lt;code&gt;pmxcfs&lt;/code&gt; itself, so it goes first and the rest of the stack picks up the new identity behind it. &lt;code&gt;pvecm updatecerts -f&lt;/code&gt; matters because the web UI certificate carries the old hostname in its subject, and browsers do care.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I would skip step 5 entirely. Nothing about a rename requires deleting the old node directory, and it is the only command in the whole procedure that can lose you something. Leaving it costs a few kilobytes and buys you a second copy of every guest config for nothing.&lt;/p&gt;

&lt;p&gt;I would also treat the gate in step 4 as the actual work rather than as paperwork. Every other step here either succeeds visibly or fails loudly at you. Steps 3 and 4 are the only two that can quietly do nothing, and they sit directly in front of the one command you cannot undo.&lt;/p&gt;

&lt;p&gt;The rename took about thirty minutes. Cleaning up after my own impatience took another fifteen, and that part gets its own write-up next week. Every guest kept running throughout, which says more about how Proxmox treats a live config directory than it does about my procedure.&lt;/p&gt;




&lt;h2&gt;
  
  
  A note on the addresses
&lt;/h2&gt;

&lt;p&gt;Hostnames and addresses in this post are documentation placeholders. Any IPs elsewhere in this series use the ranges reserved for documentation under RFC 5737 and RFC 2606. The architecture is real; the specific names are stand-ins so nothing here maps to a live target.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pve.proxmox.com/wiki/Proxmox_Cluster_File_System_(pmxcfs)" rel="noopener noreferrer"&gt;Proxmox pmxcfs documentation&lt;/a&gt; - what &lt;code&gt;/etc/pve&lt;/code&gt; actually is&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pve.proxmox.com/wiki/Cluster_Manager" rel="noopener noreferrer"&gt;Proxmox cluster manager&lt;/a&gt; - &lt;code&gt;pvecm&lt;/code&gt; reference&lt;/li&gt;
&lt;li&gt;More homelab write-ups: &lt;a href="https://www.iamkay.eu/blog" rel="noopener noreferrer"&gt;iamkay.eu/blog&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>proxmox</category>
      <category>sysadmin</category>
      <category>clustering</category>
      <category>homelab</category>
    </item>
    <item>
      <title>The runner that was busy on someone else's job</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Thu, 13 Aug 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/captkay/the-runner-that-was-busy-on-someone-elses-job-3ag3</link>
      <guid>https://dev.to/captkay/the-runner-that-was-busy-on-someone-elses-job-3ag3</guid>
      <description>&lt;p&gt;My CI pipeline sat pending for ten minutes. The runner was healthy. So was every other pipeline in the queue. The default setting was the bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Healthy and stuck at the same time
&lt;/h2&gt;

&lt;p&gt;GitLab Runner ships with &lt;code&gt;concurrent = 1&lt;/code&gt; in &lt;code&gt;/etc/gitlab-runner/config.toml&lt;/code&gt;. That one line quietly makes every pipeline run single-file across every project sharing that runner. Not per project. Across all of them. So a chatty repo pushing every few minutes can starve everything else, and the symptom is the least helpful one possible: your job sits &lt;code&gt;pending&lt;/code&gt; while &lt;code&gt;gitlab-runner status&lt;/code&gt; cheerfully reports "healthy."&lt;/p&gt;

&lt;p&gt;Healthy and stuck at the same time is the tell. The runner is not broken. It is busy, on something that is not yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  The diagnosis
&lt;/h2&gt;

&lt;p&gt;The check that confirmed it runs inside the runner's container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker ps &lt;span class="nt"&gt;--filter&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;runner-
docker inspect &amp;lt;build-container&amp;gt; &lt;span class="nt"&gt;--format&lt;/span&gt; &lt;span class="s1"&gt;'{{.Config.Env}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read &lt;code&gt;CI_PROJECT_PATH&lt;/code&gt; and &lt;code&gt;CI_PIPELINE_ID&lt;/code&gt; off the running build container and you learn exactly which project is holding the single lane. In my case it was a different repo entirely, mid-build, with mine queued politely behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix and the trade
&lt;/h2&gt;

&lt;p&gt;The fix is one number: bump to &lt;code&gt;concurrent = 2&lt;/code&gt;, or higher, watching RAM, because every extra lane is another set of job containers running at once. Back up the config first, restart the runner, and the pending pipelines unblock within about thirty seconds.&lt;/p&gt;

&lt;p&gt;The broader lesson is about defaults. &lt;code&gt;concurrent = 1&lt;/code&gt; is tuned for one project per runner, which is the simplest possible case and rarely the real one. The moment your runner serves several repos, that default stops protecting you and starts throttling you. "Healthy but not moving" is the signature of a default you have outgrown, not a component that has failed.&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>devops</category>
      <category>gitlab</category>
      <category>sre</category>
    </item>
    <item>
      <title>The hole I never had to open</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Tue, 11 Aug 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/captkay/the-hole-i-never-had-to-open-37c3</link>
      <guid>https://dev.to/captkay/the-hole-i-never-had-to-open-37c3</guid>
      <description>&lt;p&gt;My family cloud is reachable from anywhere on the internet, and I never opened a single firewall port to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hole I did not want
&lt;/h2&gt;

&lt;p&gt;For years the default answer to "how do I reach my home server from outside" was port forwarding: poke a hole in your router, expose a service to the whole internet, and hope your patching keeps up with the people scanning for it. I did not want that hole. So there is not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the tunnel replaces it
&lt;/h2&gt;

&lt;p&gt;Instead I run cloudflared, Cloudflare's tunnel agent, in a small container on an isolated DMZ segment. It makes an outbound TLS connection to Cloudflare's edge and holds it open. When someone requests &lt;code&gt;cloud.iamkay.eu&lt;/code&gt;, the request arrives at Cloudflare, travels back down that already-open tunnel to cloudflared, and cloudflared hands it to my internal reverse proxy, which routes it to Nextcloud. The connection is always outbound from my side. My router's inbound rules never change, because the answer to "who is allowed in" stays nobody.&lt;/p&gt;

&lt;p&gt;The full path is worth spelling out: phone on mobile data, to Cloudflare's edge, down the tunnel to cloudflared, to the Traefik virtual IP, to Nextcloud, to the NAS behind it. No port forward anywhere in that chain.&lt;/p&gt;

&lt;h2&gt;
  
  
  The blast radius is one line
&lt;/h2&gt;

&lt;p&gt;The part I like most is how small the exposure is. cloudflared lives on its own DMZ VLAN, and the firewall rule letting it reach the internal network is exactly one line: source &lt;code&gt;203.0.113.10&lt;/code&gt;, destination the proxy virtual IP at &lt;code&gt;10.99.99.29&lt;/code&gt;, port 443, nothing else. If cloudflared were ever compromised, it cannot SSH anywhere, cannot reach the vault, cannot touch Nextcloud's storage directly. It has one job and the firewall enforces exactly that job.&lt;/p&gt;

&lt;p&gt;It is free for personal use, and you get Cloudflare's DDoS protection and WAF in front of your services as a bonus. If you are still port forwarding self-hosted services in 2026, this is the switch to make.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on the addresses
&lt;/h2&gt;

&lt;p&gt;The IP addresses and hostnames in this post are documentation placeholders on reserved ranges, not my real ones. The public route &lt;code&gt;cloud.iamkay.eu&lt;/code&gt; is real because it is a destination I want people to reach; the internal IPs are stand-ins on purpose. The architecture is exactly how I run it. Nothing here points at a live target.&lt;/p&gt;

</description>
      <category>security</category>
      <category>networking</category>
      <category>selfhosted</category>
      <category>homelab</category>
    </item>
    <item>
      <title>The failover that fooled me</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Thu, 06 Aug 2026 17:23:37 +0000</pubDate>
      <link>https://dev.to/captkay/the-failover-that-fooled-me-5fbb</link>
      <guid>https://dev.to/captkay/the-failover-that-fooled-me-5fbb</guid>
      <description>&lt;p&gt;My reverse proxy had VRRP failover. I killed the process directly to test it, and the traffic black-holed anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup that looked complete
&lt;/h2&gt;

&lt;p&gt;I run a Traefik pair with keepalived holding a shared virtual IP between them. The idea is simple and standard: if the active node dies, the IP moves to the standby and traffic keeps flowing. And it does, if the whole node dies. Pull the power on the master and the VIP is on the backup in a couple of seconds.&lt;/p&gt;

&lt;p&gt;The gap is what happens when the box lives but the application does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Node up, app down, VIP stuck
&lt;/h2&gt;

&lt;p&gt;keepalived on its own watches only the VRRP heartbeat between the two peers. That heartbeat proves the box is alive and on the network. It says nothing about whether the application on that box is actually serving. So when I killed the Traefik process but left the machine running, the heartbeat kept ticking, keepalived saw a healthy master, and the virtual IP sat happily on a node that was no longer answering a single connection. The failover I thought I had covered exactly the failure that is least likely (a whole machine dying) and missed the one that is most likely (a process crashing).&lt;/p&gt;

&lt;h2&gt;
  
  
  The five lines that fix it
&lt;/h2&gt;

&lt;p&gt;The fix is a health check that keepalived runs on a timer and folds into its own priority:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;vrrp_script&lt;/span&gt; &lt;span class="n"&gt;chk_traefik&lt;/span&gt; {
  &lt;span class="n"&gt;script&lt;/span&gt; &lt;span class="s2"&gt;"curl -sf http://localhost/ping || exit 1"&lt;/span&gt;
  &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="n"&gt;fall&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
  &lt;span class="n"&gt;rise&lt;/span&gt; &lt;span class="m"&gt;3&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reference it from the &lt;code&gt;vrrp_instance&lt;/code&gt; &lt;code&gt;track_script&lt;/code&gt; block, and now keepalived lowers its own priority the moment the app stops answering, which hands the VIP to the standby. The same pattern works for nginx, haproxy, or any single process sitting behind a virtual IP.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it comes back to
&lt;/h2&gt;

&lt;p&gt;The heartbeat proves the box is up. The &lt;code&gt;track_script&lt;/code&gt; proves the app is up. If your VIP fronts an application, you need both, and the only reliable way to know you have both is to test by killing the process, not the machine. The machine-death test passes on a broken config. The process-death test is the one that tells the truth.&lt;/p&gt;

</description>
      <category>sre</category>
      <category>linux</category>
      <category>homelab</category>
      <category>devops</category>
    </item>
    <item>
      <title>My passwords no longer leave my house</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Thu, 06 Aug 2026 17:22:46 +0000</pubDate>
      <link>https://dev.to/captkay/my-passwords-no-longer-leave-my-house-13oa</link>
      <guid>https://dev.to/captkay/my-passwords-no-longer-leave-my-house-13oa</guid>
      <description>&lt;p&gt;My password vault runs on a box in my house now. Every credential I have syncs to my phone and my browser the same way it did when I paid Bitwarden for it, except the encrypted database lives on hardware I can walk over and touch.&lt;/p&gt;

&lt;p&gt;That sentence is either reassuring or terrifying depending on how you feel about being your own backup department, and I want to be honest about both.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually is
&lt;/h2&gt;

&lt;p&gt;Vaultwarden is a reimplementation of the Bitwarden server in Rust. It speaks the same API as the official server, which is the whole point: the official Bitwarden browser extension, the mobile app, and the desktop client all work against it unchanged. You point them at your own server URL instead of bitwarden.com and they cannot tell the difference. The clients stay official and audited. Only the server is swapped.&lt;/p&gt;

&lt;p&gt;The reason to run Vaultwarden instead of the official self-hosted Bitwarden server is footprint. The official server is a stack of containers and wants a real chunk of RAM. Vaultwarden is a single process with a SQLite database, sitting at around 50 MB resident. It runs in a Docker container inside an LXC on my cluster and I mostly forget it exists. It lives behind Traefik, which terminates TLS, so the clients reach it at &lt;code&gt;https://vault.hm.example.com&lt;/code&gt; with a proper certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that is not optional
&lt;/h2&gt;

&lt;p&gt;Here is where self-hosting a vault stops being a fun weekend project and becomes a responsibility. That SQLite file, &lt;code&gt;db.sqlite3&lt;/code&gt;, is the vault. Not a cache of it, not a copy, the actual encrypted store of every password I have. If I lose it and have no backup, I have locked myself out of my own life. Bitwarden's hosted service handles this with geo-redundant storage across regions. When you self-host, that is now your job.&lt;/p&gt;

&lt;p&gt;So the backup discipline came before I trusted a single real password to it. Proxmox Backup Server takes a daily snapshot of the whole container. That covers hardware failure, a bad upgrade, a fat-fingered delete. It does not cover the house burning down, and a backup that only exists in the same building as the original is one accident away from not existing. The next step, and the one that actually brings this to parity with a hosted service, is an encrypted off-site copy. I am pushing the backup to Backblaze B2 through rclone with client-side encryption, so the copy that leaves the house is already ciphertext before it hits the wire and I hold the only key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it is not behind my SSO
&lt;/h2&gt;

&lt;p&gt;I run Authelia in front of most of my internal services, and Vaultwarden is deliberately not one of them. The Bitwarden browser extension and mobile app talk to the server's API directly on every sync. A forward-auth proxy answers unauthenticated requests with an HTML login page, which the extension has no idea what to do with, so it just fails to sync.&lt;/p&gt;

&lt;p&gt;Vaultwarden does not need Authelia in front of it anyway. The vault is end-to-end encrypted with a master password the server never sees, admin registration is disabled, and Bitwarden's own brute-force throttle is on. Wrapping it in a second login would break the clients to protect data that is already encrypted at rest with a key I never upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tradeoff, stated plainly
&lt;/h2&gt;

&lt;p&gt;I traded roughly $40 a year and Bitwarden's geo-redundant infrastructure for full control of where my most sensitive data lives, at the cost of owning the backup risk myself. That trade is only worth it if the backup discipline is real. A self-hosted vault with no off-site copy is not cheaper than Bitwarden, it is just riskier and free, which is a bad deal dressed up as a good one.&lt;/p&gt;

&lt;p&gt;Paired with daily PBS snapshots and an encrypted off-site copy, I am comfortable calling it parity. The vault is mine, the clients are the same ones I already trusted, and the only thing I added was the obligation to take backups seriously.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on the addresses
&lt;/h2&gt;

&lt;p&gt;The internal hostname in this post, &lt;code&gt;vault.hm.example.com&lt;/code&gt;, is a documentation placeholder on a reserved example domain, not the real name on my network. Any IP addresses elsewhere in this series use the ranges set aside for documentation. The architecture is real. The specific names and addresses are stand-ins on purpose, so nothing here maps to a live target.&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>security</category>
      <category>homelab</category>
      <category>devops</category>
    </item>
    <item>
      <title>The DL380 had been off for fifteen months</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:53:44 +0000</pubDate>
      <link>https://dev.to/captkay/the-dl380-had-been-off-for-fifteen-months-5h5i</link>
      <guid>https://dev.to/captkay/the-dl380-had-been-off-for-fifteen-months-5h5i</guid>
      <description>&lt;p&gt;I powered down an HP DL380 G7 in early 2025 and did not touch it for fifteen months. It sat in a closet through a job change. On 27 May 2026 I went back to it.&lt;/p&gt;

&lt;p&gt;Would the PSUs hold. Would the disks spin clean. Would Proxmox boot. Would I remember the passwords.&lt;/p&gt;

&lt;h2&gt;
  
  
  First boot
&lt;/h2&gt;

&lt;p&gt;Front panel button. Fans spun up. POST stopped on a memory training failure: one DIMM in CPU socket 1, "uncorrectable error during training."&lt;/p&gt;

&lt;p&gt;Almost opened the chassis to reseat. Waited ten minutes first. A forum post had warned me cold-stored hardware needs a few minutes to settle. Power cycled. Clean on the second attempt. No memory complaints in any boot since.&lt;/p&gt;

&lt;p&gt;Wait through the first symptom once before you touch anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proxmox came up easily
&lt;/h2&gt;

&lt;p&gt;F11 at POST. Picked the boot SSD on the P410i. Proxmox came up on kernel 6.5.13-3-pve. Root password I still remembered, typed at the USB keyboard plugged into the back of the chassis.&lt;/p&gt;

&lt;p&gt;First job at that console: move the host onto the current network. The VLAN and address from 2024 were gone. Edited &lt;code&gt;/etc/network/interfaces&lt;/code&gt; over &lt;code&gt;vi&lt;/code&gt;, ran &lt;code&gt;ifreload -a&lt;/code&gt;, watched the new bridge come up, walked back to the laptop. SSH worked first try.&lt;/p&gt;

&lt;p&gt;From there everything ran over SSH. &lt;code&gt;qm list&lt;/code&gt; returned twenty-two VMs and one LXC. None running. Past-me had set &lt;code&gt;onboot=0&lt;/code&gt; on every guest before the shutdown, which is a discipline I have not always had.&lt;/p&gt;

&lt;p&gt;Started the NAS (&lt;code&gt;qm start 189&lt;/code&gt;), let OpenMediaVault bring up its NFS exports, checked &lt;code&gt;cat /proc/mdstat&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;md0 : active raid5 sdd[2] sdc[1] sdb[0]
      838860288 blocks super 1.2 level 5, 512k chunk, algorithm 2 [3/3] [UUU]

md1 : active raid5 sdh[3] sdg[2] sdf[1] sde[0]
      1431655936 blocks super 1.2 level 5, 512k chunk, algorithm 2 [4/4] [UUUU]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both arrays clean. The btrfs filesystems on top were readable. Fifteen months of cold did nothing to the storage layer.&lt;/p&gt;

&lt;p&gt;iLO was the hard half.&lt;/p&gt;

&lt;h2&gt;
  
  
  The iLO problem
&lt;/h2&gt;

&lt;p&gt;I wanted remote console access. The DL380 G7 has iLO 3, HP's out-of-band management processor from 2010. The final firmware shipped in December 2020, version 1.94 (&lt;a href="https://en.wikipedia.org/wiki/HP_Integrated_Lights-Out" rel="noopener noreferrer"&gt;HPE iLO firmware list&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;iLO 3 v1.94 only negotiates TLS 1.0. Chrome returned &lt;code&gt;ERR_SSL_VERSION_OR_CIPHER_MISMATCH&lt;/code&gt; with no override. Edge same answer. Firefox connected after I set &lt;code&gt;security.tls.version.min = 1&lt;/code&gt; in &lt;code&gt;about:config&lt;/code&gt;. Fresh Firefox profile, dedicated to this BMC and nothing else.&lt;/p&gt;

&lt;p&gt;The TLS workaround I had. The bigger problem was the iLO IP I had configured in 2024 was on a VLAN that no longer existed in 2026. Admin password I still remembered. I just had no route to the login page. The Proxmox host had a current address, but iLO sat on a dedicated NIC behind one that nothing on the current network could speak to.&lt;/p&gt;

&lt;p&gt;In-band IPMI saved me a chassis open.&lt;/p&gt;

&lt;h2&gt;
  
  
  In-band IPMI
&lt;/h2&gt;

&lt;p&gt;iLO speaks IPMI 2.0 over a KCS (Keyboard Controller Style) channel that the host OS sees as &lt;code&gt;/dev/ipmi0&lt;/code&gt;. Root on the host gives you admin access to the BMC. No BMC password needed; you already proved you have the keys to Linux.&lt;/p&gt;

&lt;p&gt;Debian 12 needed &lt;code&gt;ipmitool&lt;/code&gt;. The host had no internet (network was still half-rebuilt), so I sideloaded the &lt;code&gt;.deb&lt;/code&gt; stack from the laptop: &lt;code&gt;ipmitool 1.8.19-4+deb12u2&lt;/code&gt; plus &lt;code&gt;freeipmi-common 1.6.10-1&lt;/code&gt; and &lt;code&gt;libfreeipmi17 1.6.10-1+b1&lt;/code&gt;, all SHA256-verified. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ipmitool user list 1
&lt;span class="go"&gt;ID  Name             Callin  Link Auth   IPMI Msg   Channel Priv Limit
1                    true    false       false      Unknown (0x00)
2   kay              true    true        true       ADMINISTRATOR
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Admin in slot 2 (slot 1 is the IPMI-spec empty placeholder). Always list before you write.&lt;/p&gt;

&lt;p&gt;Rotated the admin password. The recorded one still worked. Cheap insurance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ipmitool user &lt;span class="nb"&gt;set &lt;/span&gt;password 2 &lt;span class="s1"&gt;'a-new-strong-password'&lt;/span&gt;
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ipmitool channel setaccess 1 2 &lt;span class="nv"&gt;callin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on &lt;span class="nv"&gt;ipmi&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on &lt;span class="nb"&gt;link&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;on &lt;span class="nv"&gt;privilege&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual rescue was the IP. LAN on iLO 3 is channel 2, not 1. Channel 1 errors out, and anything you read online about "channel 1 LAN" is iLO 4 or non-HP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ipmitool lan &lt;span class="nb"&gt;set &lt;/span&gt;2 ipsrc static
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ipmitool lan &lt;span class="nb"&gt;set &lt;/span&gt;2 ipaddr 10.99.99.3
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ipmitool lan &lt;span class="nb"&gt;set &lt;/span&gt;2 netmask 255.255.255.0
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ipmitool lan &lt;span class="nb"&gt;set &lt;/span&gt;2 defgw ipaddr 10.99.99.1
&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ipmitool mc reset cold
&lt;span class="go"&gt;Sent cold reset command to MC
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cold reset is where I burned forty minutes the first time. iLO 3 has gratuitous ARP disabled. Changing the IP does not make the BMC announce its new binding, and the upstream switch keeps the stale ARP entry for the old IP. &lt;code&gt;ipmitool mc reset cold&lt;/code&gt; power-cycles the BMC's network stack, which is what forces the announce. Without it you watch pings drop, certain one of your &lt;code&gt;lan set&lt;/code&gt; commands was rejected, when in fact all four were accepted just fine.&lt;/p&gt;

&lt;p&gt;One more iLO 3 gotcha. The BMC does not log IPMI admin operations to the SEL that &lt;code&gt;ipmitool sel list&lt;/code&gt; returns. The audit trail lives in HP's Integrated Management Log, reachable only through the web UI, RIBCL, or &lt;code&gt;hponcfg&lt;/code&gt;, and RIBCL needs the same TLS handshake modern OpenSSL refuses. So you do recovery work and there is no on-host record. Keep your own log.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it taught me
&lt;/h2&gt;

&lt;p&gt;iLO was harder than Proxmox. That ratio will hold for any pre-2014 enterprise server. Hypervisor and storage are still maintained. The BMC is a frozen artefact of whatever firmware shipped before the vendor stopped caring.&lt;/p&gt;

&lt;p&gt;Treat it like that. BMC on its own VLAN, no internet path. Credential on paper outside the BMC; recovery is in-band IPMI, which needs the host up. Cold-start drill once a year. Starting from scratch today I would buy a newer server.&lt;/p&gt;

&lt;p&gt;The box came back. Data intact. Three sessions over four days. Fifteen months of cold did nothing irreversible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/HP_Integrated_Lights-Out" rel="noopener noreferrer"&gt;HPE iLO firmware history (Wikipedia)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ipmitool/ipmitool" rel="noopener noreferrer"&gt;&lt;code&gt;ipmitool&lt;/code&gt; upstream&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Where this fits: the DL380 G7 stays as temporary quorum until a 3-node EliteDesk G5 plus WRX90 Threadripper Pro cluster takes over. More on that build in a future post.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.iamkay.eu/blog/cold-server-boot?utm_source=devto&amp;amp;utm_medium=cross-post&amp;amp;utm_campaign=post-01-cold-server-boot" rel="noopener noreferrer"&gt;iamkay.eu&lt;/a&gt;, where I write about running production infrastructure: the failures, the fixes, and the tradeoffs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>proxmox</category>
      <category>homelab</category>
      <category>linux</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>The eight login forms I deleted</title>
      <dc:creator>Kingsley Kanu</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:52:16 +0000</pubDate>
      <link>https://dev.to/captkay/the-eight-login-forms-i-deleted-2f5d</link>
      <guid>https://dev.to/captkay/the-eight-login-forms-i-deleted-2f5d</guid>
      <description>&lt;p&gt;For a while every self-hosted service in my homelab had its own login form. Eight services, eight password prompts, eight places a weak password could hide. That is not security, it is friction pretending to be security.&lt;/p&gt;

&lt;p&gt;Authelia fixed it in about two hours. This is what it does, why I chose it over the heavier option, and the one class of service I deliberately left outside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;Authelia sits behind Traefik as a ForwardAuth middleware. Every request to an internal service gets checked against Authelia first. One login sets a cookie scoped to my internal domain, and every service under that domain trusts the cookie. Log in once, you are through to all of them. TOTP is enforced for anything in the admin group, so the sensitive services always ask for a second factor.&lt;/p&gt;

&lt;p&gt;The internal domain here is &lt;code&gt;hm.example.com&lt;/code&gt;, a documentation placeholder. The real one is a private split-horizon domain that only resolves inside the house. The shape is what matters: one parent domain, a wildcard cookie, and every service living as a subdomain under it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default that matters
&lt;/h2&gt;

&lt;p&gt;The design decision worth copying is the default. Authelia's access control starts at &lt;code&gt;deny&lt;/code&gt;, and every service I want reachable is an explicit allow rule. Adding a service means adding a line. Forgetting to add a line means the service is locked, not open. That is the failure mode I want. A system that fails closed is one you can extend without lying awake wondering what you left exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not Keycloak
&lt;/h2&gt;

&lt;p&gt;People reach for Keycloak here, and for an organisation with real identity needs it is the right tool: federation, user self-service, a full admin console. For five users it is a lot of moving parts to run and patch. Authelia is a single Go binary with a file-based user database, argon2id password hashing, and a SQLite session store. Sessions expire after five minutes idle, one hour hard cap, with a one-month "remember me" for trusted devices. It does less, and for this scale that is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The service I left outside
&lt;/h2&gt;

&lt;p&gt;Forward-auth returns HTML login pages. That is fine for a browser, and wrong for a sync client. The Nextcloud mobile app, CalDAV, CardDAV, and git over SSH all speak their own auth on every call and have no idea what to do with a redirect to an HTML login form. Put them behind forward-auth and they fail silently.&lt;/p&gt;

&lt;p&gt;So those services stay outside Authelia and lean on their own authentication: Nextcloud's brute-force throttle and app passwords, git's SSH keys. The rule I follow is to match the auth pattern to what the client can actually handle, rather than wrapping everything in the same layer because it feels more secure.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it comes back to
&lt;/h2&gt;

&lt;p&gt;Single sign-on for a small system is a one-day project, not a quarter-long identity programme. One gatekeeper, a deny-by-default policy, TOTP on the admin group, and a short list of services that stay outside because their clients cannot speak the protocol. That is enough to stop running eight separate password forms, which was the whole point.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.iamkay.eu/blog/authelia-sso?utm_source=devto&amp;amp;utm_medium=cross-post&amp;amp;utm_campaign=post-06-authelia-sso" rel="noopener noreferrer"&gt;iamkay.eu&lt;/a&gt;, where I write about running production infrastructure: the failures, the fixes, and the tradeoffs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>selfhosted</category>
      <category>homelab</category>
      <category>sso</category>
    </item>
  </channel>
</rss>
