<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Thib&apos;s Blog</title><description>On digital citizenship</description><link>https://ergaster.org/</link><item><title>I realized that A cheap VPS is a good front</title><link>https://ergaster.org/thoughts/front-server/</link><guid isPermaLink="true">https://ergaster.org/thoughts/front-server/</guid><description>I host services at home, but I don&apos;t want to give me ip to the world. A small VPS and haproxy protect me.</description><pubDate>Fri, 22 May 2026 16:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I have a server at home. It runs a Kubernetes cluster and a few services. I want to expose them to the Internet, so I can e.g. share public links from my Nextcloud, or synchronize my Kobo reader with &lt;a href=&quot;https://grimmory.org/&quot;&gt;Grimmory&lt;/a&gt;. But I don&apos;t want to expose my home IP to the world, and I want to have some reasonable protection against unsophisticated DoS attacks.&lt;/p&gt;
&lt;p&gt;I realized that I can achieve that with a cheap VPS that acts as a front, &lt;a href=&quot;https://www.haproxy.org/&quot;&gt;HAProxy&lt;/a&gt;, and &lt;a href=&quot;https://www.wireguard.com/&quot;&gt;Wireguard&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;I rented a tiny VPS for €4/month at &lt;a href=&quot;https://infrawire.fr/en&quot;&gt;Infrawire&lt;/a&gt; (1 vCPU, 2 GB RAM, 25 GB NVMe). I installed a Debian 13 on it, because I want that front server to be as stable and low maintenance as possible, and installed the Debian-packaged HAProxy onto it. I also installed Wireguard. The VPS has a publicly accessible IP, so it will be my Wireguard server: my server at home can reach the VPS to establish a tunnel, the opposite is not true.&lt;/p&gt;
&lt;p&gt;On my k3s node, I&apos;ve installed Wireguard as well. I configured Wireguard on the VPS and my k3s node to establish a tunnel between the two. I&apos;ve also bound the sshd on my VPS to the wireguard address. Infrawire offers a console so I can unstick myself if I locked me out of my own server (e.g. by misconfiguring Wireguard on any side, or if my server at home had any failure).&lt;/p&gt;
&lt;p&gt;I pointed all my DNS records to the VPS. The HAProxy is a &quot;dumb&quot; tcp forwarder, so I can keep operating like before on my cluster. In particular, HAProxy doesn&apos;t do TLS termination. My certificates are fetched on my cluster by cert-manager like before, using the http-01 challenge and Let&apos;s Encrypt. I could also move to dns-01 challenges, but http-01 just works and lets me switch to a registrar without an API if need be.&lt;/p&gt;
&lt;p&gt;That way, I don&apos;t need a fixed IP at home, and I don&apos;t have to do any port-forwarding from my home router to my k3s cluster. Even better: the VPS has an anti-DDoS protection included, and I can also configure HAProxy to refuse too many connections from a same IP, I can make it close TCP connections that take too long to establish, and more. If my VPS gets hammered, I can still access my services from within my home network.&lt;/p&gt;
</content:encoded></item><item><title>TIL that Yubikeys are convenient for Linux login</title><link>https://ergaster.org/til/yubikey-unlock-laptop/</link><guid isPermaLink="true">https://ergaster.org/til/yubikey-unlock-laptop/</guid><description>Typing a long password when you need to log in or get sudo privileges is annoying. Yubikeys can make it easier.</description><pubDate>Tue, 28 Apr 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I got myself a Yubikey recently, and I wanted to use it as a nice convenience to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Grant me sudo privileges&lt;/li&gt;
&lt;li&gt;Unlock my session&lt;/li&gt;
&lt;li&gt;Decrypt my LUKS-encrypted disk&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I&apos;ve only managed to do the first two, since they both rely on Linux Pluggable Authentication Modules (PAM). Luckily for me, one of PAM&apos;s modules supports U2F, the standard Yubikeys rely on.&lt;/p&gt;
&lt;p&gt;First I need to install &lt;code&gt;pam-u2f&lt;/code&gt; to add U2F support to PAM, and &lt;code&gt;pamu2fcfg&lt;/code&gt; to configure my key.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ sudo rpm-ostree install pam-u2f pamu2fcfg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since I&apos;m running an immutable OS I need to reboot, and then I can create the correct directory and file to dump an U2F key into it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ mkdir -p ~/.config/Yubico
$ pamu2fcfg &amp;gt; ~/.config/Yubico/u2f_keys
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I make sure to have a root session open in case I lock myself out of sudoers.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ sudo su
#
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In a different terminal, I can edit the sudoers file to add this line&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#%PAM-1.0
auth       sufficient   pam_u2f.so cue openasuser
auth       include      system-auth
account    include      system-auth
password   include      system-auth
session    optional     pam_keyinit.so revoke
session    required     pam_limits.so
session    include      system-auth
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I save this file and open a new terminal. I type in &lt;code&gt;sudo vi&lt;/code&gt; and it asks me to touch my FIDO authenticator before opening vi! If I touch the Yubikey, it indeed opens vi with root privileges.&lt;/p&gt;
&lt;p&gt;Let&apos;s break down the line:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;auth&lt;/code&gt; for authentication&lt;/li&gt;
&lt;li&gt;&lt;code&gt;sufficient&lt;/code&gt; passing this authentication challenge is enough (it&apos;s not an additional factor of authentication)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;pam_u2f.so&lt;/code&gt; the module we load is for U2F, the standard Yubikeys use&lt;/li&gt;
&lt;li&gt;&lt;code&gt;cue&lt;/code&gt; print &quot;Please touch the FIDO authenticator.&quot; when the user needs to authenticate&lt;/li&gt;
&lt;li&gt;&lt;code&gt;openasuser&lt;/code&gt; to fetch the authentication file without root privileges&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It&apos;s also possible to use it to unlock my session, but it would be a bit reckless to allow anyone with my Yubikey to log into my laptop. If my backpack gets stolen and it has both my Yubikey and my laptop, anyone can log in.&lt;/p&gt;
&lt;p&gt;It&apos;s possible to make the login screen require either my user password, or all of&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The Yubikey itself&lt;/li&gt;
&lt;li&gt;The PIN of the Yubikey&lt;/li&gt;
&lt;li&gt;Me to touch the Yubikey&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If someone fails more than three times to enter the correct PIN, the Yubikey will lock itself and require a PUK to be unlocked. This gives me an additional layer of security, and it&apos;s more convenient than having to type a full length passphrase.&lt;/p&gt;
&lt;p&gt;I&apos;ve added the following line to &lt;code&gt;/etc/pam.d/greetd&lt;/code&gt; (the greeter I use):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#%PAM-1.0
auth       sufficient  pam_u2f.so cue openasuser pinverification=1 userpresence=1
auth       substack    system-auth
[...]
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!warning] I can lose my Yubikey&lt;/p&gt;
&lt;p&gt;I use my Yubikey as a nice convenience to set up a weaker PIN while not compromising too much on security. I use it &lt;em&gt;instead&lt;/em&gt; of a password, no in addition to it.&lt;/p&gt;
&lt;p&gt;Since I can lose or break my Yubikey and I don&apos;t want to buy two of them, I make the U2F login &lt;code&gt;sufficient&lt;/code&gt; but not &lt;code&gt;required&lt;/code&gt;. This means I can still fallback to password authentication if I lose my Yubikey.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Finally, DankMaterialShell uses its own lockscreen manager too. I still want to be able to fallback to password authentication if need be, so I&apos;ll configure it to accept U2F OR the password, not both.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;This means that the lockscreen will call &lt;code&gt;/etc/pam.d/dankshell-u2f&lt;/code&gt; to know what to do when the screen is locked. Since this file doesn&apos;t exist, I can create it with the following content.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#%PAM-1.0
auth sufficient pam_u2f.so cue openasuser pinverification=1 userpresence=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I need a fallback for when I don&apos;t have my Yubikey, so I also create the one for this occasion&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#%PAM-1.0
auth include system-auth
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, I have a consistent setup where both my login and lock screen require me to plug my key, enter its PIN and touch it, or enter my full password. When it comes to sudo, I can only touch my key without requiring an PIN.&lt;/p&gt;
&lt;p&gt;My next quest will be to use my Yubikey to unlock my LUKS-encrypted disk.&lt;/p&gt;
</content:encoded></item><item><title>TIL that Minikube mounts volumes as root</title><link>https://ergaster.org/til/minikube-volumes-root/</link><guid isPermaLink="true">https://ergaster.org/til/minikube-volumes-root/</guid><description>Minikube is awesome to test deployments on a dev machine, but it can backfire in interesting ways.</description><pubDate>Tue, 21 Apr 2026 07:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When I have to play with a container image I have never met before, I like to deploy it on a test cluster to poke and prod it. I usually did that on a k3s cluster, but recently I&apos;ve moved to Minikube to bring my test cluster with me when I&apos;m on the go.&lt;/p&gt;
&lt;p&gt;Minikube is a tiny one-node Kubernetes cluster meant to run on development machines. It&apos;s useful to test &lt;code&gt;Deployments&lt;/code&gt; or &lt;code&gt;StatefulSets&lt;/code&gt; with images you are not familiar with and build proper helm charts from them.&lt;/p&gt;
&lt;p&gt;It provides volumes of the &lt;code&gt;hostPath&lt;/code&gt; type by default. The major caveat of &lt;code&gt;hostPath&lt;/code&gt; volumes is that they&apos;re &lt;a href=&quot;https://kubernetes.io/docs/concepts/storage/volumes/#hostpath-volume-types:~:text=Some%20files,volume&quot;&gt;mounted as root by default&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I usually handle mismatched ownership with a &lt;code&gt;securityContext&lt;/code&gt; like the following to instruct the container to run with a specific UID and GID, and to make the volume owned by a specific group.&lt;/p&gt;
&lt;p&gt;Typically in a &lt;code&gt;StatefulSet&lt;/code&gt; it looks like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: myapp
# [...]
spec:
# [...]
  template:
# [...]
    spec:
      securityContext:
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
      containers:
        - name: myapp
          volumeMounts:
            - name: data
              mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
# [...]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In this configuration:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Processes in the Pod &lt;code&gt;myapp&lt;/code&gt; will run with UID 10001 and GID 10001.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;/data&lt;/code&gt; directory mounted from the &lt;code&gt;data&lt;/code&gt; volume will belong to group 10001 as well.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;code&gt;securityContext&lt;/code&gt; usually solves the problem, but that&apos;s not how &lt;code&gt;hostPath&lt;/code&gt; works. For &lt;code&gt;hostPath&lt;/code&gt; volumes, the &lt;code&gt;securityContext.fsGroup&lt;/code&gt; property is silently ignored.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!success] Init Container to the Rescue!&lt;/p&gt;
&lt;p&gt;The solution in this specific case is to use an &lt;a href=&quot;https://kubernetes.io/docs/concepts/workloads/pods/init-containers/&quot;&gt;initContainer&lt;/a&gt; as root to &lt;code&gt;chown&lt;/code&gt; the volume mounts to the unprivileged user.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In practice it will look like this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: myapp
# [...]
spec:
# [...]
  template:
# [...]
    spec:
      securityContext:
        runAsUser: 10001
        runAsGroup: 10001
        fsGroup: 10001
      initContainers:
        - name: fix-perms
          image: busybox
          command:
            [&quot;sh&quot;, &quot;-c&quot;, &quot;chown -R 10001:10001 /data&quot;]
          securityContext:
            runAsUser: 0
          volumeMounts:
            - name: data
              mountPath: /data
      containers:
        - name: myapp
          volumeMounts:
            - name: data
              mountPath: /data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
# [...]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It took me a little while to figure it out, because I was used to testing my &lt;code&gt;StatefulSets&lt;/code&gt; on k3s. K3s uses a local path provisioner, which gives me &lt;code&gt;local&lt;/code&gt; volumes, not &lt;code&gt;hostPath&lt;/code&gt; ones like Minikube.&lt;/p&gt;
&lt;p&gt;In production I don&apos;t need the &lt;code&gt;initContainer&lt;/code&gt; to fix permissions since I&apos;m deploying this on an EKS cluster.&lt;/p&gt;
</content:encoded></item><item><title>TIL that Pagefind does great client-side search</title><link>https://ergaster.org/til/pagefind-static-search/</link><guid isPermaLink="true">https://ergaster.org/til/pagefind-static-search/</guid><description>Providing search on a static website seemed impossible, but Pagefind builds a static index and queries it on the client side.</description><pubDate>Thu, 16 Apr 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I post more and more content on my website. What was visible at glance then is now more difficult to look for. I wanted to implement search, but it is a static website. It means that everything is built once, and then published somewhere as final, immutable pages. I can&apos;t send a request for search and get results in return.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;Or that&apos;s what I thought! &lt;a href=&quot;https://pagefind.app/&quot;&gt;Pagefind&lt;/a&gt; is a neat javascript library that does two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It produces an index of the content right after building the static site.&lt;/li&gt;
&lt;li&gt;It provides 2 web components to insert in my pages: &lt;code&gt;&amp;lt;pagefind-modal&amp;gt;&lt;/code&gt; that is the search modal itself, hidden by default, and &lt;code&gt;&amp;lt;pagefind-modal-trigger&amp;gt;&lt;/code&gt; that looks like a search field and opens the modal.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The &lt;code&gt;pagefind-modal&lt;/code&gt; component looks up the index when the user types a request. The index is a static file, so there is not need for a backend that processes queries. Of course this only works for basic queries, but it&apos;s a great rool already!&lt;/p&gt;
&lt;p&gt;Pagefind is also easy to customize via &lt;a href=&quot;https://pagefind.app/docs/css-variables/&quot;&gt;a list of CSS variables&lt;/a&gt;. Adding it to this website was very straightforward.&lt;/p&gt;
</content:encoded></item><item><title>I realized that Niri can have gorgeous animation</title><link>https://ergaster.org/thoughts/niri-gorgeous-animation/</link><guid isPermaLink="true">https://ergaster.org/thoughts/niri-gorgeous-animation/</guid><description>I already loved having my windows neatly organized on my computer, but now I&apos;m also in awe when they appear.</description><pubDate>Wed, 15 Apr 2026 06:30:00 GMT</pubDate><content:encoded>&lt;p&gt;I was a huge fan of Niri already. It&apos;s a scrolling tiling window manager. Roughly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When I open an app it takes the full height and a pre-configured width on the screen.&lt;/li&gt;
&lt;li&gt;When I open more apps and the screen was already full, it pushes the existing apps off screen.&lt;/li&gt;
&lt;li&gt;It can stack windows in columns to have a more compact view&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;It means that windows always take the optimal amount of space, and they&apos;re very neatly organized. It&apos;s extremely pleasant to use and keyboard friendly.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;Don&apos;t mind the apparent slowness: this was recorded on a 10 year old laptop, opening OBS is enough to make its CPU go brr. When OBS is not running, Niri is buttery smooth.&lt;/p&gt;
&lt;p&gt;But now I&apos;ve learned that Niri supports user-provided GLSL shaders for several animations. Roughly: you can animate how windows appear and disappear (and other events, but let&apos;s keep things simple).&lt;/p&gt;
&lt;p&gt;Some people out there have created collections of shaders that work wonderfully for Niri:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/liixini/shaders&quot;&gt;https://github.com/liixini/shaders&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/XansiVA/nirimation&quot;&gt;https://github.com/XansiVA/nirimation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My personal favorite is the glitchy one.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;In a world of uniform UIs, these frivolous, unnecessary and creative ways to interact with users are a breath of fresh air! Those animations are healing my inner 14 year old.&lt;/p&gt;
</content:encoded></item><item><title>TIL that Animated AVIFs make lightweight videos</title><link>https://ergaster.org/til/animated-avif-lightweight/</link><guid isPermaLink="true">https://ergaster.org/til/animated-avif-lightweight/</guid><description>Embeding a video in a markdown post is not that easy. AVIF turns videos into lightweight animated images.</description><pubDate>Mon, 13 Apr 2026 07:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Sometimes in my posts I need to show a screen recording. Videos can get heavy rapidly and take a lot of time to load.&lt;/p&gt;
&lt;p&gt;I also write my posts in markdown which has syntax to include images:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;![Alt text describing the image](path to the image)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Using that syntax for videos doesn&apos;t work though. Since html is valid markdown, it&apos;s possible to manually add &lt;code&gt;&amp;lt;video&amp;gt;&lt;/code&gt; tags, but it&apos;s a bit more tedious.&lt;/p&gt;
&lt;p&gt;It&apos;s also possible to use &lt;code&gt;ffmpeg&lt;/code&gt; to convert a mp4 video into a looping animated AVIF. The command to do it is&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ffmpeg -i demo.mp4 -loop 0 demo.avif
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AVIF also compresses very well, without losing too much detail.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ls -lh
total 1.8M
-rw-r--r--. 1 thib thib 566K Apr 11 09:26 typst-live-preview.avif
-rw-r--r--. 1 thib thib 1.2M Apr  8 22:02 typst-live-preview.mp4
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The support for AVIF in browsers &lt;a href=&quot;https://caniuse.com/avif&quot;&gt;is excellent&lt;/a&gt;, sitting at more than 94% as of writing.&lt;/p&gt;
&lt;p&gt;&lt;s&gt;My only remaining gripe is that &lt;a href=&quot;https://github.com/withastro/astro/issues/16267&quot;&gt;Astro chokes on AVIF images&lt;/a&gt; when trying to optimize images in Markdown posts. A workaround for it is to store the AVIFs as static assets so Astro doesn&apos;t try to optimize them.&lt;/s&gt;&lt;/p&gt;
&lt;p&gt;Update: The excellent Astro maintainers have &lt;a href=&quot;https://github.com/withastro/astro/issues/16267&quot;&gt;fixed the AVIF issue&lt;/a&gt; and the workaround above is no longer needed.&lt;/p&gt;
</content:encoded></item><item><title>TIL that Kubernetes can give you a shell into a crashing container</title><link>https://ergaster.org/til/kubernetes-crash-shell/</link><guid isPermaLink="true">https://ergaster.org/til/kubernetes-crash-shell/</guid><description>I didn&apos;t get why my container crashed, but I couldn&apos;t check from within. It turns out that Kubernetes has the tools for it.</description><pubDate>Fri, 10 Apr 2026 08:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When a container crashes, it can be for several reasons. Sometimes the log won&apos;t tell you much about why the container crashed, and you can&apos;t get a shell into that container because... it has already crashed. It turns out that &lt;code&gt;kubectl debug&lt;/code&gt; can let you do exactly that.&lt;/p&gt;
&lt;p&gt;I was trying to ship &lt;a href=&quot;https://www.helfertool.org/&quot;&gt;Helfertool&lt;/a&gt; on our Kubernetes cluster. The firs step was to get it to work locally in my Minikube. The container I was deploying kept crashing, with an error message that put me on the right track: &lt;code&gt;Cannot write to log directory. Exiting.&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;The container expected me to mount a volume on &lt;code&gt;/log&lt;/code&gt; so it could write logs, which I did. I wanted to run a quick test from within the container to see if I could create a file in that directory. But when your container has already crashed you can&apos;t get a shell into it.&lt;/p&gt;
&lt;p&gt;My better informed colleague &lt;a href=&quot;https://sandhose.fr/&quot;&gt;Quentin&lt;/a&gt; told me about &lt;a href=&quot;https://kubernetes.io/docs/tasks/debug/debug-application/debug-running-pod/#copying-a-pod-while-changing-its-command&quot;&gt;&lt;code&gt;kubectl debug&lt;/code&gt;&lt;/a&gt;, a command that lets me create a copy of the crashing container but with a different &lt;code&gt;COMMAND&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;So instead of running its normal program, I can ask the container to run &lt;code&gt;sh&lt;/code&gt; with the following command&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ kubectl debug mypod -it \
    --copy-to=mypod-debug \
    --container=my-pods-image \
    -- sh
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And just like that I have shell inside a similar container. Using this trick I could confirm that I can&apos;t &lt;code&gt;touch&lt;/code&gt; a file in that &lt;code&gt;/log&lt;/code&gt; directory because it belongs to &lt;code&gt;root&lt;/code&gt; while my container is running unprivileged.&lt;/p&gt;
&lt;p&gt;That&apos;s a great trick to troubleshoot from within a crashing container!&lt;/p&gt;
</content:encoded></item><item><title>TIL that Helix and Typst are a match made in heaven</title><link>https://ergaster.org/til/helix-typst-match-in-heaven/</link><guid isPermaLink="true">https://ergaster.org/til/helix-typst-match-in-heaven/</guid><description>Helix makes extensive use of Language Servers, but tinymist pushes it beyond what I thought was possible.</description><pubDate>Thu, 09 Apr 2026 08:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I love Markdown with all my heart. It&apos;s a markup language so simple to understand that even people who are not software engineers can use it in a few minutes.&lt;/p&gt;
&lt;p&gt;The flip side of that coin if that Markdown is limited. It can let you create various title levels, bold, italics, strikethrough, tables, links, and a bit more, but not so much.&lt;/p&gt;
&lt;p&gt;When it comes to more complex documents, most people resort to full fledged office suite like Microsoft Office or LibreOffice. Both have their merits, but office file formats are brittle and heavy.&lt;/p&gt;
&lt;p&gt;The alternative is to use another more complex markup language. Academics used to be into LaTeX but it&apos;s often tedious to use. Typst emerged more recently as a simpler yet useful markup language to create well formatted documents.&lt;/p&gt;
&lt;p&gt;Tinymist is a language server for Typst. It provides the usual services a Language server provides, like semantic highlighting, code actions, formatting, etc.&lt;/p&gt;
&lt;p&gt;But it really stands out by providing a &lt;em&gt;live&lt;/em&gt; preview feature that keeps your cursor in sync &lt;em&gt;in Helix&lt;/em&gt; when you are clicking around in the live preview!&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;I only had to install it with&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ brew install tinymist
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I then configured Helix to use tinymist for Typst documents, enabling live preview along the way. This happens of course in &lt;code&gt;~/.config/helix/languages.toml&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[language-server.tinymist]
command = &quot;tinymist&quot;
config = { preview.background.enabled = true, preview.background.args = [&quot;--data-plane-host=127.0.0.1:23635&quot;, &quot;--invert-colors=never&quot;, &quot;--open&quot;] }

[[language]]
name = &quot;typst&quot;
language-servers = [&quot;tinymist&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A warm thank you to my lovely friend &lt;a href=&quot;https://haecker.io/&quot;&gt;Felix&lt;/a&gt; for showing me the live preview mode of tinymist!&lt;/p&gt;
</content:encoded></item><item><title>TIL that Git can locally ignore files</title><link>https://ergaster.org/til/git-local-ignore/</link><guid isPermaLink="true">https://ergaster.org/til/git-local-ignore/</guid><description>Everybody knows about .gitignore, but you can also ignore files locally without littering the shared .gitignore.</description><pubDate>Tue, 07 Apr 2026 17:35:00 GMT</pubDate><content:encoded>&lt;p&gt;When editing markdown, I love using &lt;a href=&quot;https://helix-editor.com/&quot;&gt;Helix&lt;/a&gt; (best editor in the world). I rely on three language servers to help me do it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/rvben/rumdl&quot;&gt;rumdl&lt;/a&gt; to check markdown syntax and enforce the rules decided by a project&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/artempyanykh/marksman&quot;&gt;marksman&lt;/a&gt; to get assistance when creating links&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://writewithharper.com/&quot;&gt;harper-ls&lt;/a&gt; to check for spelling or grammar mistakes&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All of these are configured in my &lt;code&gt;~/.config/helix/languages.toml&lt;/code&gt; configuration file, so it applies globally to all the markdown I edit. But when I edit &lt;a href=&quot;https://matrix.org/twim&quot;&gt;This Week In Matrix&lt;/a&gt; at work, things are different.&lt;/p&gt;
&lt;p&gt;To edit those posts, we let our community report their progress in a Matrix room, we collect them into a markdown file that we then editorialized. This is a perfect fit for Helix (best editor in the world) and its language servers.&lt;/p&gt;
&lt;p&gt;Helix has two features that make it a particularly good fit for the job&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The diagnostics view&lt;/li&gt;
&lt;li&gt;Jumping to next error with &lt;code&gt;]d&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;It is possible to &lt;a href=&quot;/til/helix-filter-picker/&quot;&gt;filter out pickers&lt;/a&gt;, but it becomes tedious to do so. For this project specifically, I want to disable harper-ls entirely. Helix supports per-project configuration by creating a &lt;code&gt;.helix/languages.toml&lt;/code&gt; file at the project&apos;s root.&lt;/p&gt;
&lt;p&gt;It&apos;s a good solution to override my default config, but now I have an extra &lt;code&gt;.helix&lt;/code&gt; directory that git wants to track. I could add it to the &lt;code&gt;.gitignore&lt;/code&gt;, but that would also add it to everyone else&apos;s &lt;code&gt;.gitignore&lt;/code&gt;, even if they don&apos;t use Helix (best editor in the world) yet.&lt;/p&gt;
&lt;p&gt;It turns out that there is a local-only equivalent to &lt;code&gt;.gitignore&lt;/code&gt;, and it&apos;s
&lt;code&gt;.git/info/exclude&lt;/code&gt;. The syntax is the same as &lt;code&gt;.gitignore&lt;/code&gt; but it&apos;s not committed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; several people reached out to point out that there are global options to locally ignore files, if you don&apos;t need to do it per-project. Those options are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The global &lt;code&gt;~/.config/git/ignore&lt;/code&gt; file, with the same syntax as &lt;code&gt;.gitignore&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;The configuration variable &lt;code&gt;core.excludesFile&lt;/code&gt; to specify a file that contains which patterns to ignore, like a &lt;code&gt;.gitignore&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I can&apos;t believe I didn&apos;t need this earlier in my life.&lt;/p&gt;
</content:encoded></item><item><title>TIL that You can filter Helix pickers</title><link>https://ergaster.org/til/helix-filter-picker/</link><guid isPermaLink="true">https://ergaster.org/til/helix-filter-picker/</guid><description>Helix has a system of pickers. They can get crowded, but you can filter out information.</description><pubDate>Tue, 07 Apr 2026 17:20:00 GMT</pubDate><content:encoded>&lt;p&gt;Helix has a system of pickers. It&apos;s a pop up window to open files, or open diagnostics coming from a Language Server.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;The diagnostics picker displays data in columns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;severity&lt;/li&gt;
&lt;li&gt;source&lt;/li&gt;
&lt;li&gt;code&lt;/li&gt;
&lt;li&gt;message&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Sometimes it can get very crowded, especially when you have plenty of hints but few actual errors. I didn&apos;t know it, but Helix supports filtering in pickers!&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;By typing &lt;code&gt;%severity WARN&lt;/code&gt; I only get warnings. I can even shorten it to &lt;code&gt;%se&lt;/code&gt; (and not &lt;code&gt;%s&lt;/code&gt;, since &lt;code&gt;source&lt;/code&gt; also starts with an &lt;code&gt;s&lt;/code&gt;). The full syntax is well documented in the &lt;a href=&quot;https://docs.helix-editor.com/master/pickers.html#filtering-picker-results&quot;&gt;pickers documentation&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item><item><title>TIL that Sveltia is a good CMS for Astro</title><link>https://ergaster.org/til/sveltia-cms-astro/</link><guid isPermaLink="true">https://ergaster.org/til/sveltia-cms-astro/</guid><description>I needed a CMS to blurt out quick thoughts on my website from my phone. Sveltia does the job.</description><pubDate>Tue, 31 Mar 2026 09:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This website is built with the static site generator &lt;a href=&quot;https://astro.build/&quot;&gt;Astro&lt;/a&gt;. All my content is written in markdown and uploaded to a git repository. Once the content is merged into the &lt;code&gt;main&lt;/code&gt; branch, Cloudflare deploys it publicly. The process to publish involves:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Creating a new markdown file.&lt;/li&gt;
&lt;li&gt;Filling it with thoughts.&lt;/li&gt;
&lt;li&gt;Pushing it to a new branch.&lt;/li&gt;
&lt;li&gt;Waiting for CI to check my content respects some rules.&lt;/li&gt;
&lt;li&gt;Pressing the merge button.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is pretty involved and of course requires access to a computer. This goes directly against the goal I’ve set for myself to &lt;a href=&quot;/thoughts/less-friction-publish/&quot;&gt;reduce friction to publish&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;I wanted a simple  solution to write and publish short posts directly from mobile, without hosting an additional service.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Such an app is called a git-based headless CMS. &lt;a href=&quot;https://decapcms.org/&quot;&gt;Decap CMS&lt;/a&gt; is the most frequently cited solution for git-based content management, but it has two show-stoppers for me:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It’s not mobile friendly (yet, since 2017) although there are &lt;a href=&quot;https://gist.github.com/searls/7fd2c3223571a58a81006e7da66bd064&quot;&gt;community workarounds&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;It’s not entirely client-side. You need to host a serverless script e.g. on a Cloudflare Worker to complete authentication.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Because my website is completely static, it’s easy to take it off GitHub and Cloudflare and move it elsewhere. I want the CMS solution I choose to be purely client-side, so it doesn’t get in the way of moving elsewhere.&lt;/p&gt;
&lt;p&gt;It turns out that &lt;a href=&quot;https://sveltiacms.app/&quot;&gt;Sveltia&lt;/a&gt;, an API-compatible and self-proclaimed successor to Decap, is a good fit for this job, with a few caveats.&lt;/p&gt;
&lt;p&gt;Sveltia is a mobile-friendly Progressive Web App (PWA) that doesn’t require a backend. It&apos;s a static app that can be added to my static website. It has a simple configuration file to describe what fields each post expects (title, publication date, body, etc).&lt;/p&gt;
&lt;p&gt;Once the configuration and authentication are done, I have access to a lightweight PWA that lets me create new posts.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;The authentication is straightforward for technical people. I need to paste a GitHub Personal Access Token (PAT) in the login page, and that&apos;s it. Sveltia will fetch the existing content and display it.&lt;/p&gt;
&lt;p&gt;The PWA itself is also easy to deploy: I need to add a page served under the &lt;code&gt;/admin&lt;/code&gt; route, that imports the app. I could just import it from a third party CDN, but there’s also a npm package for it. It allows me to serve the javascript as a first party instead, all while easily staying up to date.&lt;/p&gt;
&lt;p&gt;I installed it with&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ pnpm add @sveltia/cms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I then created an Astro page under &lt;code&gt;src/pages/admin/index.astro&lt;/code&gt; with the following content&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;title=&quot;src/pages/admin/index.astro&quot;
&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
  &amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;utf-8&quot; /&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot; /&amp;gt;
    &amp;lt;title&amp;gt;Content Manager – ergaster.org&amp;lt;/title&amp;gt;
    &amp;lt;script&amp;gt;
    import { init } from &quot;@sveltia/cms&quot;;
    init();
    &amp;lt;/script&amp;gt;
  &amp;lt;/head&amp;gt;
  &amp;lt;body&amp;gt;&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I also created the config file under &lt;code&gt;public/admin/config.yml&lt;/code&gt; with &lt;a href=&quot;https://sveltiacms.app/en/docs/collections/entries&quot;&gt;Sveltia Entry Collections&lt;/a&gt; matching my Astro content collections. The setup is straightforward and well documented.&lt;/p&gt;
&lt;p&gt;Sveltia has a few caveats though:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It can only work on a single branch, and not create a new branch per post. According to the maintainer, it should be possible to create new branches with “&lt;a href=&quot;https://sveltiacms.app/en/docs/workflows/editorial&quot;&gt;Editorial Workflow&lt;/a&gt;” &lt;a href=&quot;https://github.com/sveltia/sveltia-cms/issues/706#issuecomment-4155443399&quot;&gt;by Q2 or Q3 this year&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;It pushes content directly to its target branch, &lt;em&gt;including drafts&lt;/em&gt;. I still want to run CI checks before merging my content, so I’ve created a &lt;code&gt;drafts&lt;/code&gt; branch and configured Sveltia to push content there. Once the CI checks have passed I merge the branch manually from the GitHub mobile app.&lt;/li&gt;
&lt;li&gt;Having a single target branch also means I can only have one draft coming from Sveltia at a time. If I edited two drafts concurrently on the &lt;code&gt;drafts&lt;/code&gt; branch, they would both be published the next time I merged &lt;code&gt;drafts&lt;/code&gt; into &lt;code&gt;main&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/sveltia/sveltia-cms/issues/705&quot;&gt;It’s clunky to rename a picture&lt;/a&gt;  uploaded via Sveltia.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Those are not deal breakers to me. The maintainer seems reactive, and the Editorial Workflow feature coming in Q2 or Q3 will fix the remaining clunkiness.&lt;/p&gt;
</content:encoded></item><item><title>Gear review: Garmin Forerunner 165</title><link>https://ergaster.org/gear/garmin-forerunner-165/</link><guid isPermaLink="true">https://ergaster.org/gear/garmin-forerunner-165/</guid><description>&lt;p&gt;Last year I bought one of those rock solid, simple, sturdy Casio Watches that are supposed to last you a long time. I still love it with all my heart and sometimes wear it, but my primary watch is now the Garmin Forerunner 165.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;A dozen years ago I got into running and installed an app on my phone to track my progress. I kept pushing harder to beat my previous records, and eventually injured myself badly. I used to think that the quantified self was the root of all evil and that it was the reason why I overtrained. It actually was out of ego, and it turns out that quantified self &lt;em&gt;and&lt;/em&gt; coaching to make sense of the data can yield amazing results.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!success] I got a Garmin Forerunner 165 and I am very happy with it.&lt;/p&gt;
&lt;p&gt;I recommend caution nonetheless: the watch is a great tool to gather metrics about how you run, but it is a terrible replacement for a coach. Working with a professional will give you much better results.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Why I got it&lt;/h2&gt;
&lt;p&gt;In my mid 30s I realized that my metabolism wasn&apos;t what it used to be. I started gaining weight and felt uneasy in my body. After two kids &lt;em&gt;and today&apos;s geopolitics&lt;/em&gt;, my mental health started to degrade too.&lt;/p&gt;
&lt;p&gt;I&apos;ve decided to get back into running to help with both aspects. Exercising makes the body burn calories and produce endorphins. Both are useful to feel better. Yes, it&apos;s only in my mid 30s that I realized that exercising was a physiological need. A need I had neglected for too long.&lt;/p&gt;
&lt;p&gt;But the last time I got into running I injured myself badly. I&apos;m the least competitive person against others, but I&apos;m very competitive against myself. This means I&apos;m subject to overtraining. I needed something to keep me on track.&lt;/p&gt;
&lt;p&gt;Several friends had Garmin watches and told me that their watches actually prevented them from overtraining. That was my cue: I would buy one, and use it to get back in shape. Even better: the model I wanted, the Forerunner 165, could last 11 days on a single charge. It means I could wear it at night to follow my sleep patterns, and it would vibrate gently on my wrist to wake me up silently. This promised to be a low maintenance and useful watch.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!info] In summary&lt;/p&gt;
&lt;p&gt;I bought the Forerunner 165 to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Get back into running.&lt;/li&gt;
&lt;li&gt;Prevent overtraining.&lt;/li&gt;
&lt;li&gt;Follow my sleep patterns.&lt;/li&gt;
&lt;li&gt;Not babysit the watch or be constantly nagged by it.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;First impressions&lt;/h2&gt;
&lt;p&gt;I bought the watch at the end of August 2025, and I started running immediately with it. In addition to the watch, I also bought a pair of Merrell Trail Glove 7: &quot;barefoot&quot; shoes that have such a thin sole that you have to land on the front of the foot and not on the heel.&lt;/p&gt;
&lt;p&gt;I installed the Garmin Connect app on my phone, and I was delighted to see that I didn&apos;t any subscription to start a coaching plan. I enrolled in a Garmin Coach program for beginners, and I started following it. At the end of each run, the watch would ask me how I felt, and each run was more painful than the last. I thought it was just muscles building up so I kept following the program. And this is how I injured myself.&lt;/p&gt;
&lt;p&gt;I went to a physiotherapist, and we started a specific training plan for barefoot shoes. Of course I quit the Garmin Coach one. He taught me that barefoot shoes require a higher cadence (number of steps per minute) than regular running shoes. I could configure the watch to keep track of my cadence during my runs. A gauge would tell me if I ran too few or too many steps per minute.&lt;/p&gt;
&lt;p&gt;After the physiotherapy sessions ended, I could keep running normally. As of writing, I go running three times a week. Each session is between 6 and 12km long.&lt;/p&gt;
&lt;h2&gt;What I like&lt;/h2&gt;
&lt;p&gt;I didn&apos;t want a smart watch because I don&apos;t want it to pester me, and I don&apos;t want to charge it every day. On those two fronts the Forerunner delivered. I don&apos;t receive any of my phone notifications on my watch, and it doesn&apos;t pester me with anything during the day. I use my watch when I need it, not when it needs me.&lt;/p&gt;
&lt;p&gt;As for the battery, it is fantastic for this type of watch. With 3 runs a week, my watch lasts 9 to 10 days on a single charge. I keep my watch at night so it monitors my sleep too. And charging is fast: I can charge it from 10 to 100% in about 1.5 hour.&lt;/p&gt;
&lt;p&gt;I can confidently go to sleep with it and know it will still have plenty of battery to wake me up the next day with a gentle vibration on my wrist. I &lt;em&gt;hate&lt;/em&gt; alarms that scream at you in the morning. This gentle nudge is infinitely better.&lt;/p&gt;
&lt;p&gt;I have pathologically bad sleep and the watch does a good job at tracking it. It even helped me detect sleep apnea, that doctors later confirmed. The watch gives you several metrics for the night: how long you&apos;ve slept, your average and resting heart rate, your average and lowest respiration and more. It can also measure your pulse oximetry, but that depletes the battery twice as fast as if it doesn&apos;t. That watch also supports tracking naps.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;When it comes to exercising, I only use it for running. I can&apos;t say anything about its accuracy, but my physiotherapist seemed to believe that all the measures were plausible.&lt;/p&gt;
&lt;p&gt;The wristband has many holes, making it easy to adjust. It is comfortable to wear, even during exercise when the wrist can swell and sweat a bit. It is also slightly elastic, so it can stretch a bit for extra comfort.&lt;/p&gt;
&lt;p&gt;It is possible to use the watch only to track how you run, or to configure workouts in the app depending on your objectives. During my physiotherapy training I would make it track my cadence, but you can track a lot more metrics. You can also configure several steps, e.g. warm-up, light run, fast run, series, etc.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;There are also built-in, free Garmin Coach programs depending on your needs, but I can&apos;t say I have a positive experience with them. If you&apos;re new to running I &lt;em&gt;really&lt;/em&gt; recommend going to a professional coach or physiotherapist to get you started.&lt;/p&gt;
&lt;p&gt;At €230, the watch is not cheap, but seems fairly priced for the amount of value I get from it. I also don&apos;t expect to replace it anytime soon.&lt;/p&gt;
&lt;h2&gt;What I don&apos;t like&lt;/h2&gt;
&lt;p&gt;The watch has an odd &lt;a href=&quot;https://www.garmin.com/en-US/garmin-technology/running-science/physiological-measurements/recovery-time/&quot;&gt;recovery time&lt;/a&gt; metric can be difficult to understand: you can workout lightly to recover. To this day I&apos;m not entirely sure what it does.&lt;/p&gt;
&lt;p&gt;Beyond that it&apos;s a good watch I can&apos;t complain about!&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;I’m very happy with my Forerunner 165. It’s important to bear in mind it’s just a tool, not something that can replace a human coach. If your knees or tendons hurt and the watch tells you to go running, don’t. Go see a professional.&lt;/p&gt;
</description><pubDate>Mon, 30 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Last year I bought one of those rock solid, simple, sturdy Casio Watches that are supposed to last you a long time. I still love it with all my heart and sometimes wear it, but my primary watch is now the Garmin Forerunner 165.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;A dozen years ago I got into running and installed an app on my phone to track my progress. I kept pushing harder to beat my previous records, and eventually injured myself badly. I used to think that the quantified self was the root of all evil and that it was the reason why I overtrained. It actually was out of ego, and it turns out that quantified self &lt;em&gt;and&lt;/em&gt; coaching to make sense of the data can yield amazing results.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!success] I got a Garmin Forerunner 165 and I am very happy with it.&lt;/p&gt;
&lt;p&gt;I recommend caution nonetheless: the watch is a great tool to gather metrics about how you run, but it is a terrible replacement for a coach. Working with a professional will give you much better results.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;Why I got it&lt;/h2&gt;
&lt;p&gt;In my mid 30s I realized that my metabolism wasn&apos;t what it used to be. I started gaining weight and felt uneasy in my body. After two kids &lt;em&gt;and today&apos;s geopolitics&lt;/em&gt;, my mental health started to degrade too.&lt;/p&gt;
&lt;p&gt;I&apos;ve decided to get back into running to help with both aspects. Exercising makes the body burn calories and produce endorphins. Both are useful to feel better. Yes, it&apos;s only in my mid 30s that I realized that exercising was a physiological need. A need I had neglected for too long.&lt;/p&gt;
&lt;p&gt;But the last time I got into running I injured myself badly. I&apos;m the least competitive person against others, but I&apos;m very competitive against myself. This means I&apos;m subject to overtraining. I needed something to keep me on track.&lt;/p&gt;
&lt;p&gt;Several friends had Garmin watches and told me that their watches actually prevented them from overtraining. That was my cue: I would buy one, and use it to get back in shape. Even better: the model I wanted, the Forerunner 165, could last 11 days on a single charge. It means I could wear it at night to follow my sleep patterns, and it would vibrate gently on my wrist to wake me up silently. This promised to be a low maintenance and useful watch.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!info] In summary&lt;/p&gt;
&lt;p&gt;I bought the Forerunner 165 to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Get back into running.&lt;/li&gt;
&lt;li&gt;Prevent overtraining.&lt;/li&gt;
&lt;li&gt;Follow my sleep patterns.&lt;/li&gt;
&lt;li&gt;Not babysit the watch or be constantly nagged by it.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h2&gt;First impressions&lt;/h2&gt;
&lt;p&gt;I bought the watch at the end of August 2025, and I started running immediately with it. In addition to the watch, I also bought a pair of Merrell Trail Glove 7: &quot;barefoot&quot; shoes that have such a thin sole that you have to land on the front of the foot and not on the heel.&lt;/p&gt;
&lt;p&gt;I installed the Garmin Connect app on my phone, and I was delighted to see that I didn&apos;t any subscription to start a coaching plan. I enrolled in a Garmin Coach program for beginners, and I started following it. At the end of each run, the watch would ask me how I felt, and each run was more painful than the last. I thought it was just muscles building up so I kept following the program. And this is how I injured myself.&lt;/p&gt;
&lt;p&gt;I went to a physiotherapist, and we started a specific training plan for barefoot shoes. Of course I quit the Garmin Coach one. He taught me that barefoot shoes require a higher cadence (number of steps per minute) than regular running shoes. I could configure the watch to keep track of my cadence during my runs. A gauge would tell me if I ran too few or too many steps per minute.&lt;/p&gt;
&lt;p&gt;After the physiotherapy sessions ended, I could keep running normally. As of writing, I go running three times a week. Each session is between 6 and 12km long.&lt;/p&gt;
&lt;h2&gt;What I like&lt;/h2&gt;
&lt;p&gt;I didn&apos;t want a smart watch because I don&apos;t want it to pester me, and I don&apos;t want to charge it every day. On those two fronts the Forerunner delivered. I don&apos;t receive any of my phone notifications on my watch, and it doesn&apos;t pester me with anything during the day. I use my watch when I need it, not when it needs me.&lt;/p&gt;
&lt;p&gt;As for the battery, it is fantastic for this type of watch. With 3 runs a week, my watch lasts 9 to 10 days on a single charge. I keep my watch at night so it monitors my sleep too. And charging is fast: I can charge it from 10 to 100% in about 1.5 hour.&lt;/p&gt;
&lt;p&gt;I can confidently go to sleep with it and know it will still have plenty of battery to wake me up the next day with a gentle vibration on my wrist. I &lt;em&gt;hate&lt;/em&gt; alarms that scream at you in the morning. This gentle nudge is infinitely better.&lt;/p&gt;
&lt;p&gt;I have pathologically bad sleep and the watch does a good job at tracking it. It even helped me detect sleep apnea, that doctors later confirmed. The watch gives you several metrics for the night: how long you&apos;ve slept, your average and resting heart rate, your average and lowest respiration and more. It can also measure your pulse oximetry, but that depletes the battery twice as fast as if it doesn&apos;t. That watch also supports tracking naps.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;When it comes to exercising, I only use it for running. I can&apos;t say anything about its accuracy, but my physiotherapist seemed to believe that all the measures were plausible.&lt;/p&gt;
&lt;p&gt;The wristband has many holes, making it easy to adjust. It is comfortable to wear, even during exercise when the wrist can swell and sweat a bit. It is also slightly elastic, so it can stretch a bit for extra comfort.&lt;/p&gt;
&lt;p&gt;It is possible to use the watch only to track how you run, or to configure workouts in the app depending on your objectives. During my physiotherapy training I would make it track my cadence, but you can track a lot more metrics. You can also configure several steps, e.g. warm-up, light run, fast run, series, etc.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;There are also built-in, free Garmin Coach programs depending on your needs, but I can&apos;t say I have a positive experience with them. If you&apos;re new to running I &lt;em&gt;really&lt;/em&gt; recommend going to a professional coach or physiotherapist to get you started.&lt;/p&gt;
&lt;p&gt;At €230, the watch is not cheap, but seems fairly priced for the amount of value I get from it. I also don&apos;t expect to replace it anytime soon.&lt;/p&gt;
&lt;h2&gt;What I don&apos;t like&lt;/h2&gt;
&lt;p&gt;The watch has an odd &lt;a href=&quot;https://www.garmin.com/en-US/garmin-technology/running-science/physiological-measurements/recovery-time/&quot;&gt;recovery time&lt;/a&gt; metric can be difficult to understand: you can workout lightly to recover. To this day I&apos;m not entirely sure what it does.&lt;/p&gt;
&lt;p&gt;Beyond that it&apos;s a good watch I can&apos;t complain about!&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;I’m very happy with my Forerunner 165. It’s important to bear in mind it’s just a tool, not something that can replace a human coach. If your knees or tendons hurt and the watch tells you to go running, don’t. Go see a professional.&lt;/p&gt;
</content:encoded></item><item><title>I realized that You don&apos;t care</title><link>https://ergaster.org/thoughts/you-dont-care/</link><guid isPermaLink="true">https://ergaster.org/thoughts/you-dont-care/</guid><description>A lot of us maintain our own website, and you don&apos;t care. And I don&apos;t expect you to.</description><pubDate>Sun, 29 Mar 2026 16:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Quite a few of us maintain our own websites and publish our thoughts. We play in hard mode:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;We need to build our website before even publishing our first post.&lt;/li&gt;
&lt;li&gt;We don’t benefit from the network effect of bigger platforms to get eyeballs on our writing.&lt;/li&gt;
&lt;li&gt;LLMs aggressively scrape the web and can serve our thoughts or expertise to their users without them visiting our websites.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;And on top of that, you don’t care.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;And I don’t expect you to care. Like the rest of us, you are flooded with information constantly. You’re fed so many words that you read the equivalent of whole books every day. How entitled would I be to expect you to care about my words when you have to filter through every story you’re bombarded with.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;So why do we keep the small web alive?&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;I can’t speak for others, but I know why I maintain my website and why I publish my thoughts there. By increasing order of importance:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;I keep my web development skills reasonably up to date.&lt;/li&gt;
&lt;li&gt;I can shape my website to adapt to my content, and not the other way around.&lt;/li&gt;
&lt;li&gt;I have freedom of tone and vocabulary. I don’t have to censor words like &quot;suicide&quot; or &quot;sex&quot;.&lt;/li&gt;
&lt;li&gt;I write long form posts that help me shape my thoughts, develop ideas, and receive feedback from my peers and readers.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you can afford to, I can only encourage you to write and publish your thoughts on your own platform, as long as you don’t expect others to care in return.&lt;/p&gt;
</content:encoded></item><item><title>I realized that I created too much friction to publish</title><link>https://ergaster.org/thoughts/less-friction-publish/</link><guid isPermaLink="true">https://ergaster.org/thoughts/less-friction-publish/</guid><description>I love writing on my blog. I love taking a complex topic, breaking it down, understanding how things work, and writing about how things clicked for me.</description><pubDate>Sat, 28 Mar 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I love writing on my blog. I love taking a complex topic, breaking it down, understanding how things work, and writing about how things clicked for me. It serves a double purpose:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;I can organize my thoughts, ensure I understood the topic fully, and explain it to others.&lt;/li&gt;
&lt;li&gt;It helps my future self: if I forgot about the topic, I can read about what made it click for me.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;But as of writing, the last time I published something on my blog was 5 months ago.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The blogging process&lt;/h2&gt;
&lt;p&gt;My blog posts tend to be lengthy. My writing and publishing process is the following.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Take a nontrivial topic, something I didn&apos;t know about or didn&apos;t know how to do.&lt;/li&gt;
&lt;li&gt;Understand it, break it down, and get a clear picture of how things work.&lt;/li&gt;
&lt;li&gt;Write an outline for the post with the key points.&lt;/li&gt;
&lt;li&gt;Ask my smarter friends if the outline makes sense.&lt;/li&gt;
&lt;li&gt;Flesh out the outline into a proper blog posts, with all the details, code snippets, screenshots.&lt;/li&gt;
&lt;li&gt;Ask my smarter friends to review the post again.&lt;/li&gt;
&lt;li&gt;Get an illustrator to create a banner for the post, that also serves as an opengraph preview image.&lt;/li&gt;
&lt;li&gt;Publish the post.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That is a lot of work. I have many posts stuck between step 3 and 5, because they take quite a bit of time. Asking an illustrator to create a banner for the post also creates more friction: obviously I need to pay the illustrator, but I also need to wait for him to be done with the illustration.&lt;/p&gt;
&lt;h2&gt;Not everything has to be a blog post&lt;/h2&gt;
&lt;p&gt;Sometimes I have quick thoughts that I want to jot down and share with the rest of the world, and I want to be able to find it back. There are two people I follow that write a lot, often in short format.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;John Gruber on his blog &lt;a href=&quot;https://daringfireball.net/&quot;&gt;Daring Fireball&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Simon Willison, on his &lt;a href=&quot;https://simonwillison.net/&quot;&gt;Weblog&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Both of them have very short format notes. Willison even blogged about &lt;a href=&quot;https://simonwillison.net/2022/Nov/6/what-to-blog-about/&quot;&gt;what he thinks people should write about&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Reducing friction and just posting&lt;/h2&gt;
&lt;p&gt;I don&apos;t think friction should be avoided at all costs. Take emails for example: there&apos;s a delay between when you send a message and your peer receives it, or the other way around. That friction encourages longer form messages, which gives more time to organize thoughts.&lt;/p&gt;
&lt;p&gt;I also welcome the friction I have created for my own posts: I get through a proper review process and publish higher quality posts.&lt;/p&gt;
&lt;p&gt;But there&apos;s also room for spontaneity. So I&apos;ve updated my website to let me publish two smaller formats:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TILs.&lt;/strong&gt; Those are short posts about something I&apos;ve learned and found interesting.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Thoughts.&lt;/strong&gt; Those are shorter posts I jot down in less than 20 minutes to develop simple thoughts.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item><item><title>TIL that Proxmox can provision Kubernetes Persistent Volumes</title><link>https://ergaster.org/til/proxmox-k8s-volumes/</link><guid isPermaLink="true">https://ergaster.org/til/proxmox-k8s-volumes/</guid><description>Proxmox can spin up VMs for a Kubernetes cluster and provision Persistent Volumes, opening the door to volume snapshots and better storage management.</description><pubDate>Wed, 25 Mar 2026 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I wanted to dip my toes into Kubernetes for my homelab, but I knew I would need some flexibility to experiment. So instead of deploying k3s directly on my server, I&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Installed a base Debian on my server, encrypting the disk with LUKS and using LVM to partition it.&lt;/li&gt;
&lt;li&gt;Installed the Proxmox hypervisor on that base Debian&lt;/li&gt;
&lt;li&gt;Spun up a Debian VM, and installed k3s on it.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Proxmox supports &lt;a href=&quot;https://pve.proxmox.com/wiki/Storage&quot;&gt;several storage plugins&lt;/a&gt;. It allows me to create LVM Local Volumes for the VM disks for example.&lt;/p&gt;
&lt;p&gt;This setup allows me to spin up fresh VMs for my experiments, all while leaving my production k3s intact. This is great, but it came up with two problems:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;When I provision the VM for k3s I need to allocate it a massive amount of disk space. This is because k3s uses a &lt;a href=&quot;https://docs.k3s.io/add-ons/storage&quot;&gt;local path provisioner&lt;/a&gt; to provision new Persistent Volumes directly on the VM.&lt;/li&gt;
&lt;li&gt;I can&apos;t take snapshots of the Persistent Volumes when doing backups. There&apos;s a risk that the data will change while I perform the backup.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The situation looks like the following.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;On the LVM disk of the host, I create a VM for k3s. This VM has a virtual disk that doesn&apos;t rely on LVM, so it can&apos;t create LVM Logical Volumes. The local provisioner can only create volumes on the virtual disk, because it can&apos;t escape the VM to create volumes on the Proxmox host.&lt;/p&gt;
&lt;p&gt;Because the volumes are created on the virtual disk that doesn&apos;t rely on LVM, I can&apos;t use LVM snapshots to take snapshots of my volumes.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!question] Why not LVM Thin?&lt;/p&gt;
&lt;p&gt;One solution to address the massive disk requirement could be to use &lt;a href=&quot;https://pve.proxmox.com/wiki/Storage:_LVM_Thin&quot;&gt;LVM Thin&lt;/a&gt;: it would allow me to allocate a lot of space in theory, but in practice in only fills up as the VM storage gets used.&lt;/p&gt;
&lt;p&gt;I don&apos;t want to use LVM Thin because it puts me at risk of overprovisioning. I could allocate more storage than I actually have, and it would be difficult to realize that my disks are filling up before it&apos;s too late.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;My colleague &lt;a href=&quot;https://sandhose.fr&quot;&gt;Quentin&lt;/a&gt; mentioned the &lt;a href=&quot;https://github.com/sergelogvinov/proxmox-csi-plugin&quot;&gt;Proxmox CSI Plugin&lt;/a&gt;. It is a plugin that replaces k3s&apos; local path provisioner. Instead of creating the kubernetes Persistent Volumes inside the VM, it calls the Proxmox host, asks it to create a LVM Logical Volume and binds it to a Persistent Volume in kubernetes.&lt;/p&gt;
&lt;p&gt;Using the Proxmox CSI volume, the situation would look like this.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;It solves the two problems for me:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;I can now only provision a small disk for the k3s VM, since the Persistent Volumes will be created outside of the VM.&lt;/li&gt;
&lt;li&gt;Since Proxmox will create LVM Logical Volumes to provision the Persistent Volumes, I can either do a LVM Snapshot from Proxmox or use Kubernete&apos;s &lt;a href=&quot;https://kubernetes.io/docs/concepts/storage/volume-snapshots/&quot;&gt;Volume Snapshot&lt;/a&gt; feature, with &lt;a href=&quot;https://github.com/sergelogvinov/proxmox-csi-plugin/blob/main/docs/volumesnapshot.md&quot;&gt;some caveats&lt;/a&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Setting up the Proxmox-CSI-Plugin for k3s can be a bit involved, but I&apos;m writing a longer blog post about it.&lt;/p&gt;
</content:encoded></item><item><title>TIL that GNOME has launched a fellowship program</title><link>https://ergaster.org/til/gnome-fellowship/</link><guid isPermaLink="true">https://ergaster.org/til/gnome-fellowship/</guid><description>When open source nonprofits ask for donations, one common answer is &quot;I only want to fund code, I don&apos;t want to fund anything else.&quot;</description><pubDate>Tue, 24 Mar 2026 19:00:00 GMT</pubDate><content:encoded>&lt;p&gt;When open source nonprofits ask for donations, one common answer is &quot;I only want to fund code, I don&apos;t want to fund anything else.&quot; GNOME has created a &lt;a href=&quot;https://fellowship.gnome.org/&quot;&gt;Fellowship Program&lt;/a&gt; to fund direct work on GNOME, a program entirely funded by &lt;a href=&quot;https://donate.gnome.org/&quot;&gt;donations&lt;/a&gt;. This is a testament to the Foundation&apos;s maturity, as it becomes a direct contributor to the project it stewards.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;Let&apos;s take a step back to address the code-only argument. It is a misguided reaction, but I can see where its proponents are coming from. In the world of proprietary software, you pay to get your software. You don&apos;t realize that this bundles the marketing, accounting, legal, and even HR costs.&lt;/p&gt;
&lt;p&gt;In the open source world, everyone can see who contributes code and how that code is built and packaged to create a software solution. A lot of things are not shown in git commits though. A few of them are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What did it take to create the &lt;a href=&quot;https://developer.gnome.org/hig/&quot;&gt;Human Interface Guidelines&lt;/a&gt; to have a coherent suite of applications? How many designers had to meet, what research did they have to do, did they have to meet in person?&lt;/li&gt;
&lt;li&gt;What did it take to create the &lt;a href=&quot;https://developer.gnome.org/&quot;&gt;Developer Documentation&lt;/a&gt; to onboard new developers, help them make their first steps, and turn them into bigger contributors over the years?&lt;/li&gt;
&lt;li&gt;What did it take to build a website to &lt;a href=&quot;https://apps.gnome.org/&quot;&gt;advertize all the cool apps that follow the GNOME HIG&lt;/a&gt;?&lt;/li&gt;
&lt;li&gt;What did it take to set up the infrastructure the code lives on, and that builds the software we all love?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;GNOME, like many other open source projects, is first and foremost a community. This is a group of people with diverse backgrounds, diverse opinions, who try to find common ground to solve problems. They don&apos;t always agree on how to solve problems, nor necessarily on what even &lt;em&gt;is&lt;/em&gt; a problem in the first place.&lt;/p&gt;
&lt;p&gt;The role of The GNOME Foundation is to provide a place to support its community. Its role is to help its contributors find common ground. Its role is to give them the tools and opportunities to do so.&lt;/p&gt;
&lt;p&gt;Some people still don&apos;t value this, and want The GNOME Foundation to be a vendor for GNOME. They want to fund &lt;em&gt;developers&lt;/em&gt; to &lt;em&gt;produce code&lt;/em&gt;, because that&apos;s a very visible metric.&lt;/p&gt;
&lt;p&gt;For them, and for everyone who&apos;s ever wanted to give back to GNOME without knowing how, The GNOME Foundation has created a &lt;a href=&quot;https://fellowship.gnome.org/&quot;&gt;Fellowship Program&lt;/a&gt;. It will directly fund a person to work on what few people want to do in their spare time: maintenance.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Round one focuses on sustainability: improving tooling, build systems, test infrastructure, automation, documentation, developer productivity, and ongoing maintainability. We are not funding feature development: the goal is for each fellowship to leave the project in a more efficient and sustainable state.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;This is only fueled by our donations. If you want a direct pipeline between your money and GNOME development, this is it. &lt;a href=&quot;https://donate.gnome.org/&quot;&gt;Donate to GNOME&lt;/a&gt;, we can&apos;t afford not to have them when Big Tech has so much influence on our lives.&lt;/p&gt;
</content:encoded></item><item><title>From VS Code to Helix</title><link>https://ergaster.org/posts/2025/10/29-vscode-to-helix/</link><guid isPermaLink="true">https://ergaster.org/posts/2025/10/29-vscode-to-helix/</guid><description>I want less Big Tech in my life. After years of dismissing Helix as too fringe and complicated, I&apos;ve tried (and loved) it.</description><pubDate>Wed, 29 Oct 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I created the website you&apos;re reading with VS Code. Behind the scenes I use Astro, a static site generator that gets out of the way while providing nice conveniences.&lt;/p&gt;
&lt;p&gt;Using VS Code was a no-brainer: everyone in the industry seems to at least be familiar with it, every project can be opened with it, and most projects can get enhancements and syntactic helpers in a few clicks. In short: VS Code is free, easy to use, and widely adopted.&lt;/p&gt;
&lt;p&gt;A Rustacean colleague kept singing &lt;a href=&quot;https://helix-editor.com/&quot;&gt;Helix&lt;/a&gt;&apos;s praises. I discarded it because he&apos;s much smarter than I am, and I only ever use vim when I need to fiddle with files on a server. I like when things &quot;Just Work&quot; and didn&apos;t want to bother learning how to use Helix nor how to configure it.&lt;/p&gt;
&lt;p&gt;Today it has become my daily driver. Why did I change my mind? What was preventing me from using it before? And how difficult was it to get there?&lt;/p&gt;
&lt;h2&gt;Automation is a double-edged sword&lt;/h2&gt;
&lt;p&gt;Automation and technology make work easier, this is why we produce technology in the first place. But it also means you grow more dependent on the tech you use. If the tech is produced transparently by an international team or a team you trust, it&apos;s fine. But if it&apos;s produced by a single large entity that can screw you over, it&apos;s dangerous.&lt;/p&gt;
&lt;p&gt;VS Code might be open source, but in practice it&apos;s produced by Microsoft. Microsoft has a problematic relationship to consent and is shoving AI products down everyone&apos;s throat. I&apos;d rather use tools that respect me and my decisions, and I&apos;d rather not get my tools produced by already monopolistic organizations.&lt;/p&gt;
&lt;p&gt;Microsoft is also based in the USA, and the political climate over there makes me want to depend as little as possible on American tools. I know that&apos;s a long, uphill battle, but we have to start somewhere.&lt;/p&gt;
&lt;p&gt;I&apos;m not advocating for a ban against American tech in general, but for more balance in our supply chain. I&apos;m also not advocating for European tech either: I&apos;d rather get open source tools from international teams competing in a race to the top, rather than from teams in a single jurisdiction. What is happening in the USA could happen in Europe too.&lt;/p&gt;
&lt;h2&gt;Why I feared using Helix&lt;/h2&gt;
&lt;p&gt;I&apos;ve never found vim particularly pleasant to use but it&apos;s everywhere, so I figured I might just get used to it. But one of the things I never liked about vim is the number of moving pieces. By default, vim and neovim are very bare bones. They can be extended and completely modified with plugins, but I really don&apos;t like the idea of having extremely customize tools.&lt;/p&gt;
&lt;p&gt;I&apos;d rather have the same editor as everyone else, with a few knobs for minor preferences. I am subject to choice paralysis, so making me configure an editor before I&apos;ve even started editing is the best way to tank my productivity.&lt;/p&gt;
&lt;p&gt;When my colleague told me about Helix, two things struck me as improvements over vim.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Helix&apos;s philosophy is that everything should work out of the box.&lt;/strong&gt; There are a few configs and themes, but everything should work similarly from one Helix to another. All the language-specific logic is handled in Language Servers that implement the &lt;a href=&quot;https://en.wikipedia.org/wiki/Language_Server_Protocol&quot;&gt;Language Server Protocol&lt;/a&gt; standard.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;In Helix, first you select text, and then you perform operations onto it.&lt;/strong&gt; So you can visually tell what is going to be changed before you apply the change. It fits my mental model much better.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;But there are major drawbacks to Helix too:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;After decades of vim, I was scared to re-learn everything.&lt;/strong&gt; In practice this wasn&apos;t a problem at all because of the very visual way Helix works.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VS Code &quot;Just Works&quot;, and Helix sounded like more work than the few clicks from VS Code&apos;s extension store.&lt;/strong&gt; This is true, but not as bad as I had anticipated.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;After a single week of usage, Helix was already very comfortable to navigate. After a few weeks, most of the wrinkles have been ironed out and I use it as my primary editor. So how did I overcome those fears?&lt;/p&gt;
&lt;h2&gt;What Helped&lt;/h2&gt;
&lt;h3&gt;Just Do It&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;I tried Helix.&lt;/strong&gt; It can sound silly, but the very first step to get into Helix was not to overthink it. I just installed it on my mac with &lt;code&gt;brew install helix&lt;/code&gt; and gave it a go. I was not too familiar with it, so I looked up &lt;a href=&quot;https://docs.helix-editor.com/usage.html&quot;&gt;the official documentation&lt;/a&gt; and noticed there was a tutorial.&lt;/p&gt;
&lt;p&gt;This tutorial alone is what convinced me to try harder. It&apos;s an interactive and well written way to learn how to move and perform basic operations in Helix. I quickly learned how to move around, select things, surround them with braces or parenthesis. I could &lt;em&gt;see&lt;/em&gt; what I was about to do before doing it. This has been epiphany. Helix just worked the way I wanted.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;Better: I could get things done faster than in VS Code after a few minutes of learning. Being a lazy person, I never bothered looking up VS Code shortcuts. Because the learning curve for Helix is slightly steeper, you &lt;em&gt;have&lt;/em&gt; to learn those shortcuts that make moving around feel so easy.&lt;/p&gt;
&lt;p&gt;Not only did I quickly get used to Helix key bindings: my vim muscle-memory didn&apos;t get in the way at all!&lt;/p&gt;
&lt;h3&gt;Better docs&lt;/h3&gt;
&lt;p&gt;The built-in tutorial is a very pragmatic way to get started. You get results fast, you learn hands on, and it&apos;s not that long. But if you want to go further, you have to look for docs. Helix &lt;a href=&quot;https://docs.helix-editor.com/&quot;&gt;has officials docs&lt;/a&gt;. They seem to be fairly complete, but they&apos;re also impenetrable as a new user. They focus on what the editor supports and not on what I will want to do with it.&lt;/p&gt;
&lt;p&gt;After a bit of browsing online, I&apos;ve stumbled upon &lt;a href=&quot;https://helix-nikita-revencos-projects.vercel.app/start-here/basics&quot;&gt;this third-party documentation website&lt;/a&gt;. The domain didn&apos;t inspire me a lot of confidence, but the docs are really good. They are clearly laid out, use-case oriented, and they make the most of Astro Starlight to provide a great reading experience. The author &lt;a href=&quot;https://github.com/helix-editor/helix/pull/12127#issuecomment-2525902615&quot;&gt;tried to upstream these docs, but that won&apos;t happen&lt;/a&gt;. It looks like they are upstreaming their docs to the current website. I hope this will improve the quality of upstream docs eventually.&lt;/p&gt;
&lt;p&gt;After learning the basics and finding my way through the docs, it was time to ensure Helix was set up to help me where I needed it most.&lt;/p&gt;
&lt;h2&gt;Getting the most of Markdown and Astro in Helix&lt;/h2&gt;
&lt;p&gt;In my free time, I mostly use my editor for three things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Write notes in markdown&lt;/li&gt;
&lt;li&gt;Tweak my website with Astro&lt;/li&gt;
&lt;li&gt;Edit yaml to faff around my Kubernetes cluster&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Helix is a &quot;stupid&quot; text editor. It doesn&apos;t know much about what you&apos;re typing. But it supports Language Servers that implement the Language Server Protocol. Language Servers understand the document you&apos;re editing. They explain to Helix what you&apos;re editing, whether you&apos;re in a TypeScript function, typing a markdown link, etc. With that information, Helix and the Language Server can provide code completion hints, errors &amp;amp; warnings, and easier navigation in your code.&lt;/p&gt;
&lt;p&gt;In addition to Language Servers, Helix also supports plugging code formatters. Those are pieces of software that will read the document and ensure that it is consistently formatted. It will check that all indentations use spaces and not tabs, that there is a consistent number of space when indenting, that brackets are on the same line as the function, etc. In short: it will make the code pretty.&lt;/p&gt;
&lt;h3&gt;Markdown&lt;/h3&gt;
&lt;p&gt;Markdown is not really a programming language, so it might seem surprising to configure a Language Server for it. But if you remember what we said earlier, Language Servers can provide code completion, which is useful when creating links for example. &lt;a href=&quot;https://github.com/artempyanykh/marksman&quot;&gt;Marksman&lt;/a&gt; does exactly that!&lt;/p&gt;
&lt;p&gt;Since Helix &lt;a href=&quot;https://docs.helix-editor.com/lang-support.html&quot;&gt;is pre-configured to use marksman for markdown files&lt;/a&gt; we only need to install marksman and make sure it&apos;s in our &lt;code&gt;PATH&lt;/code&gt;. Installing it with homebrew is enough.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ brew install marksman
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can check that Helix is happy with it with the following command&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ hx --health markdown
Configured language servers:
  ✓ marksman: /opt/homebrew/bin/marksman
Configured debug adapter: None
Configured formatter: None
Tree-sitter parser: ✓
Highlight queries: ✓
Textobject queries: ✘
Indent queries: ✘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But Language Servers can also help Helix display errors and warnings, and &quot;code suggestions&quot; to help fix the issues. It means Language Servers are a perfect fit for... grammar checkers! Several grammar checkers exist. The most notable are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://ltex-plus.github.io/ltex-plus/&quot;&gt;LTEX+&lt;/a&gt;, the Language Server used by &lt;a href=&quot;https://languagetool.org/&quot;&gt;Language Tool&lt;/a&gt;. It supports several languages but is quite resource hungry.&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://writewithharper.com/&quot;&gt;Harper&lt;/a&gt;, a grammar checker Language Server developed by Automattic, the people behind WordPress, Tumblr, WooCommerce, Beeper and more. Harper only support English and its variants, but they intend to support more languages in the future.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I mostly write in English and want to keep a minimalistic setup. Automattic is well funded, and I&apos;m confident they will keep working on Harper to improve it. Since grammar checker LSPs can easily be changed, I&apos;ve decided to go with Harper for now.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;To install it, homebrew does the job as always:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ brew install harper
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then I edited my &lt;code&gt;~/.config/helix/languages.toml&lt;/code&gt; to add Harper as a secondary Language Server in addition to marksman&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[language-server.harper-ls]
command = &quot;harper-ls&quot;
args = [&quot;--stdio&quot;]

[[language]]
name = &quot;markdown&quot;
language-servers = [&quot;marksman&quot;, &quot;harper-ls&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally I can add a markdown linter to ensure my markdown is formatted properly. Several options exist, and &lt;a href=&quot;https://github.com/DavidAnson/markdownlint&quot;&gt;markdownlint&lt;/a&gt; is one of the most popular. My colleagues recommended the new kid on the block, a &lt;em&gt;Blazing Fast&lt;/em&gt; equivalent: &lt;a href=&quot;https://github.com/rvben/rumdl&quot;&gt;rumdl&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Installing rumdl was pretty simple on my mac. I only had to add the repository of the maintainer, and install rumdl from it.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ brew tap rvben/rumdl
$ brew install rumdl
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After that I added a new &lt;code&gt;language-server&lt;/code&gt; to my &lt;code&gt;~/.config/helix/languages.toml&lt;/code&gt; and added it to the language servers to use for the markdown &lt;code&gt;language&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[language-server.rumdl]
command = &quot;rumdl&quot;
args = [&quot;server&quot;]

[...]


[[language]]
name = &quot;markdown&quot;
language-servers = [&quot;marksman&quot;, &quot;harper-ls&quot;, &quot;rumdl&quot;]
soft-wrap.enable = true
text-width = 80
soft-wrap.wrap-at-text-width = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since my website already contained a &lt;code&gt;.markdownlint.yaml&lt;/code&gt; I could import it to the rumdl format with&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ rumdl import .markdownlint.yaml
Converted markdownlint config from &apos;.markdownlint.yaml&apos; to &apos;.rumdl.toml&apos;
You can now use: rumdl check --config .rumdl.toml .
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You might have noticed that I&apos;ve added a little quality of life improvement: soft-wrap at 80 characters.&lt;/p&gt;
&lt;p&gt;Now if you add this to your own &lt;code&gt;config.toml&lt;/code&gt; you will notice that the text is completely left aligned. This is not a problem on small screens, but it rapidly gets annoying on wider screens.&lt;/p&gt;
&lt;p&gt;Helix doesn&apos;t support centering the editor. There is &lt;a href=&quot;https://github.com/helix-editor/helix/pull/9838&quot;&gt;a PR tackling the problem&lt;/a&gt; but it has been stale for most of the year. The maintainers are overwhelmed by the number of PRs making it their way, and it&apos;s not clear if or when this PR will be merged.&lt;/p&gt;
&lt;p&gt;In the meantime, a workaround exists, with a few caveats. It is possible to add spaces to the left gutter (the column with the line numbers) so it pushes the content towards the center of the screen.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;To figure out how many spaces are needed, you need to get your terminal width with &lt;code&gt;stty&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ stty size
82 243
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In my case, when in full screen, my terminal is 243 characters wide. I need to remove the content column with from it, and divide everything by 2 to get the space needed on each side. In my case for a 243 character wide terminal with a text width of 80 characters:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;(243 - 80) / 2 = 81
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;As is, I would add 203 spaces to my left gutter to push the rest of the gutter and the content to the right. But the gutter itself has a width of 4 characters, that I need to remove from the total. So I need to subtract them from the total, which leaves me with &lt;code&gt;76&lt;/code&gt; characters to add.&lt;/p&gt;
&lt;p&gt;I can open my &lt;code&gt;~/.config/helix/config.toml&lt;/code&gt; to add a new key binding that will automatically add or remove those spaces from the left gutter when needed, to shift the content towards the center.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[keys.normal.space.t]
z = &quot;:toggle gutters.line-numbers.min-width 76 3&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now when in normal mode, pressing &amp;lt;kbd&amp;gt;Space&amp;lt;/kbd&amp;gt; then &amp;lt;kbd&amp;gt;t&amp;lt;/kbd&amp;gt; then &amp;lt;kbd&amp;gt;z&amp;lt;/kbd&amp;gt; will add/remove the spaces. Of course this workaround only works when the terminal runs in full screen mode.&lt;/p&gt;
&lt;h3&gt;Astro&lt;/h3&gt;
&lt;p&gt;Astro works like a charm in VS Code. The team behind it provides &lt;a href=&quot;https://github.com/withastro/language-tools?tab=readme-ov-file#astrojslanguage-server&quot;&gt;a Language Server&lt;/a&gt; and a &lt;a href=&quot;https://github.com/withastro/language-tools?tab=readme-ov-file#astrojsts-plugin&quot;&gt;TypeScript plugin&lt;/a&gt; to enable code completion and syntax highlighting.&lt;/p&gt;
&lt;p&gt;I only had to install those globally with&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ pnpm install -g @astrojs/language-server typescript @astrojs/ts-plugin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now we need to add a few lines to our &lt;code&gt;~/.config/helix/languages.toml&lt;/code&gt; to tell it how to use the language server&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[language-server.astro-ls]
command = &quot;astro-ls&quot;
args = [&quot;--stdio&quot;]
config = { typescript = { tsdk = &quot;/Users/thibaultmartin/Library/pnpm/global/5/node_modules/typescript/lib&quot; } }

[[language]]
name = &quot;astro&quot;
scope = &quot;source.astro&quot;
injection-regex = &quot;astro&quot;
file-types = [&quot;astro&quot;]
language-servers = [&quot;astro-ls&quot;]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can check that the Astro Language Server can be used by helix with&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ hx --health astro
Configured language servers:
  ✓ astro-ls: /Users/thibaultmartin/Library/pnpm/astro-ls
Configured debug adapter: None
Configured formatter: None
Tree-sitter parser: ✓
Highlight queries: ✓
Textobject queries: ✘
Indent queries: ✘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I also like to get a formatter to automatically make my code consistent and pretty for me when I save a file. One of the most popular code formaters out there is &lt;a href=&quot;https://prettier.io/&quot;&gt;Prettier&lt;/a&gt;. I&apos;ve decided to go with the fast and easy formatter &lt;a href=&quot;https://dprint.dev/&quot;&gt;dprint&lt;/a&gt; instead.&lt;/p&gt;
&lt;p&gt;I installed it with&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ brew install dprint
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then in the projects I want to use dprint in, I do&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ dprint init
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I might edit the &lt;code&gt;dprint.json&lt;/code&gt; file to my liking. Finally, I configure Helix to use dprint globally for all Astro projects by appending a few lines in my &lt;code&gt;~/.config/helix/languages.toml&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[[language]]
name = &quot;astro&quot;
scope = &quot;source.astro&quot;
injection-regex = &quot;astro&quot;
file-types = [&quot;astro&quot;]
language-servers = [&quot;astro-ls&quot;]
formatter = { command = &quot;dprint&quot;, args = [&quot;fmt&quot;, &quot;--stdin&quot;, &quot;astro&quot;] }
auto-format = true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One final check, and I can see that Helix is ready to use the formatter as well&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ hx --health astro
Configured language servers:
  ✓ astro-ls: /Users/thibaultmartin/Library/pnpm/astro-ls
Configured debug adapter: None
Configured formatter:
  ✓ /opt/homebrew/bin/dprint
Tree-sitter parser: ✓
Highlight queries: ✓
Textobject queries: ✘
Indent queries: ✘
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;YAML&lt;/h3&gt;
&lt;p&gt;For yaml, it&apos;s simple and straightforward: Helix is preconfigured to use &lt;code&gt;yaml-language-server&lt;/code&gt; as soon as it&apos;s in the PATH. I just need to install it with&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ brew install yaml-language-server
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Is it worth it?&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Helix really grew on me. I find it particularly easy and fast to edit code with it.&lt;/strong&gt; It takes a tiny bit more work to get the language support than it does in VS Code, but it&apos;s nothing insurmountable. There is a slightly steeper learning curve than for VS Code, but I consider it to be a good thing. It forced me to learn how to move around and edit efficiently, because there is no way to do it inefficiently. Helix remains intuitive once you&apos;ve learned the basics.&lt;/p&gt;
&lt;p&gt;I am a GNOME enthusiast, and I adhere to the same principles: &lt;strong&gt;I like when my apps work out of the box, and when I have little to do to configure them.&lt;/strong&gt; This is a strong stance that often attracts a vocal opposition. I like products that follow those principles better than those who don&apos;t.&lt;/p&gt;
&lt;p&gt;With that said, Helix sometimes feels like it is maintained by one or two people who have a strong vision, but who struggle to onboard more maintainers. As of writing, Helix has more than 350 PRs open. Quite a few bring interesting features, but the maintainers don&apos;t have enough time to review them.&lt;/p&gt;
&lt;p&gt;Those 350 PRs mean there is a lot of energy and goodwill around the project. &lt;strong&gt;People are willing to contribute. Right now, all that energy is gated, resulting in frustration&lt;/strong&gt; both from the contributors who feel like they&apos;re working in the void, and the maintainers who feel like there at the receiving end of a fire hose.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A solution to make everyone happier without sacrificing the quality of the project would be to work on a Contributor Ladder.&lt;/strong&gt; CHAOSS&apos; Dr Dawn Foster published &lt;a href=&quot;https://fastwonderblog.com/2025/08/12/governance-part-3-new-contributors-and-pathways-to-leadership/&quot;&gt;a blog post about it&lt;/a&gt;, listing interesting resources at the end.&lt;/p&gt;
</content:encoded></item><item><title>Cloud tech makes sense on-prem too</title><link>https://ergaster.org/posts/2025/08/20-cloud-tech-on-prem/</link><guid isPermaLink="true">https://ergaster.org/posts/2025/08/20-cloud-tech-on-prem/</guid><description>Running VMs at home is more complex than bare metal. But opentofu, cloud-init, and Ansible let me spin up and configure local VMs in seconds.</description><pubDate>Wed, 20 Aug 2025 10:00:00 GMT</pubDate><content:encoded>&lt;p&gt;In &lt;a href=&quot;/posts/2025/08/04-overegineering-homelab/&quot;&gt;the previous post&lt;/a&gt;, we talked about the importance to have a flexible homelab with Proxmox, and set it up. Long story short, I only have a single physical server but I like to experiment with new setups regularly. Proxmox is a baremetal hypervisor: a piece of software that lets me spin up Virtual Machines on top of my server, to act as mini servers.&lt;/p&gt;
&lt;p&gt;Thanks to this set-up I can have a long-lived VM for my (single node) production k3s cluster, and I can spin up disposable VMs to experiment with, without impacting my production.&lt;/p&gt;
&lt;p&gt;But it&apos;s more complex to install Proxmox, spin up a VM, and install k3s on it, as compared to just installing Debian and k3s on my baremetal server. We have already automated the Proxmox install process. &lt;strong&gt;Let&apos;s now automate the VM provisioning and deploy k3s on it, to make it simple and easy to re-provision a fully functional Virtual Machine on top of Proxmox!&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;In this post we will configure opentofu so it can ask Proxmox to spin up a new VM, use cloud-init to do the basic pre-configuration of the VM, and use ansible to deploy k3s on it.&lt;/p&gt;
&lt;h2&gt;Provisioning and pre-configuring a VM&lt;/h2&gt;
&lt;p&gt;OpenTofu is software I execute on my laptop. It reads file describing what I want to provision, and performs the actual provisioning. I can use it to say &quot;I want a VM with 4 vCPUs and 8GB of RAM on this Proxmox cluster,&quot; or &quot;I want to add this A record to my DNS managed by Cloudflare.&quot; I need to write this down in &lt;code&gt;.tf&lt;/code&gt; files, and invoke the &lt;code&gt;tofu&lt;/code&gt; CLI to read those files and apply the changes.&lt;/p&gt;
&lt;p&gt;Opentofu is quite flexible. It can connect to many different providers (e.g. Proxmox, AWS, Scaleway, Hetzner...) to spin up a variety of resources (e.g. a VM on Proxmox, an EC2 or EKS instance or AWS, etc). Proxmox, Amazon and other providers publish &lt;em&gt;Provider&lt;/em&gt; plugins for opentofu, available in the &lt;a href=&quot;https://search.opentofu.org/&quot;&gt;OpenTofu Registry&lt;/a&gt; (and in the &lt;a href=&quot;https://registry.terraform.io/&quot;&gt;Terraform Registry&lt;/a&gt; since opentofu is backward compatible for now).&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;h3&gt;Configuring Opentofu for Proxmox&lt;/h3&gt;
&lt;p&gt;To use Opentofu with Proxmox, you need to pick and configure an Opentofu Provider for Proxmox. There seem to be two active implementations:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://search.opentofu.org/provider/bpg/proxmox/latest&quot;&gt;bpg/proxmox&lt;/a&gt; is maintained by an individual&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://search.opentofu.org/provider/telmate/proxmox/latest&quot;&gt;telmate/proxmox&lt;/a&gt; is maintained by the Telmate organization&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The former seems to have better test coverage, and friends have used it for months without a problem. I am taking a leap of faith and picking it.&lt;/p&gt;
&lt;p&gt;The plugin needs to be configured so opentofu on my laptop can talk to Proxmox and spin up new VMs. To do so, I need to create a Proxmox service account that opentofu will use, so opentofu has sufficient privileges to create the VMs I ask it to create.&lt;/p&gt;
&lt;p&gt;I will rely on the &lt;code&gt;pveum&lt;/code&gt; (Proxmox Virtual Environment User Management) utility to create a role with the right privileges, create a new user/service account, and assign the role to the service account.&lt;/p&gt;
&lt;p&gt;Once ssh&apos;d into the Proxmox host, I can create the terraform user that opentofu will use&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# pveum user add terraform@pve
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!info] I don&apos;t have to add a password&lt;/p&gt;
&lt;p&gt;I will issue an API Key for opentofu to authenticate as this user. Not having a password reduces the attack surface by ensuring nobody can use this service account to log into the web UI.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Then let&apos;s create the role&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# pveum role add Terraform -privs &quot;Datastore.Allocate \
    Datastore.AllocateSpace \
    Datastore.AllocateTemplate \
    Datastore.Audit \
    Pool.Allocate \
    Sys.Audit \
    Sys.Console \
    Sys.Modify \
    VM.Allocate \
    VM.Audit \
    VM.Clone \
    VM.Config.CDROM \
    VM.Config.Cloudinit \
    VM.Config.CPU \
    VM.Config.Disk \
    VM.Config.HWType \
    VM.Config.Memory \
    VM.Config.Network \
    VM.Config.Options \
    VM.Console \
    VM.Migrate \
    VM.PowerMgmt \
    SDN.Use&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And now let&apos;s assign the role to the user&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# pveum aclmod / -user terraform@pve -role Terraform
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally I can create an API token&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# pveum user token add terraform@pve provider --privsep=0
┌──────────────┬──────────────────────────────────────┐
│ key          │ value                                │
╞══════════════╪══════════════════════════════════════╡
│ full-tokenid │ terraform@pve!provider               │
├──────────────┼──────────────────────────────────────┤
│ info         │ {&quot;privsep&quot;:&quot;0&quot;}                      │
├──────────────┼──────────────────────────────────────┤
│ value        │ REDACTED                             │
└──────────────┴──────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I now have a service account up and ready. Let&apos;s create a &lt;code&gt;~/Projects/infra/tofu&lt;/code&gt; folder that will contain my whole infrastructure&apos;s opentofu file. In that folder, I will create a &lt;code&gt;providers.tf&lt;/code&gt; file to declare and configure the various providers I need. For now, this will only be Proxmox. I can configure my Proxmox provider so it knows where the API endpoint is, and what API key to use.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;terraform {
  required_providers {
    proxmox = {
      source = &quot;bpg/proxmox&quot;
      version = &quot;0.80.0&quot;
    }
  }
}

provider &quot;proxmox&quot; {
  endpoint = &quot;https://192.168.1.220:8006/&quot;
  api_token = &quot;terraform@pve!provider=REDACTED&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For some operations, including VM provisioning, the API is not enough and the Proxmox provider needs to ssh into the Proxmox host to issue commands. I can configure the Proxmox provider to use my ssh agent.&lt;/p&gt;
&lt;p&gt;This way, when I call the &lt;code&gt;tofu&lt;/code&gt; command on my laptop to provision VMs on the Proxmox host, the provider will use the ssh-agent of my laptop to authenticate against the Proxmox host. This will make opentofu use my ssh keypair to authenticate. Since my ssh key is already trusted by the Proxmox host, opentofu will be able to log in seamlessly.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;provider &quot;proxmox&quot; {
  endpoint  = &quot;https://192.168.1.220:8006/&quot;
  api_token = &quot;terraform@pve!provider=REDACTED&quot;
  insecure  = true

  ssh {
    agent    = true
    username = &quot;root&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!warning] Insecure but still somewhat secure&lt;/p&gt;
&lt;p&gt;We add an &lt;code&gt;insecure&lt;/code&gt; line to our configuration. It instructs opentofu to skip the TLS verification of the certificate presented by the Proxmox host. We do this because Proxmox generates a self-signed certificate our computer doesn&apos;t trust. We will understand what this means and fix that in a further blog post.&lt;/p&gt;
&lt;p&gt;The main risk we&apos;re facing by doing so is to let another machine impersonate our Proxmox host. Since we&apos;re working on a homelab, in a home network, the chances of it happening are extraordinarily low, and this it can be considered a temporarily acceptable.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;After moving to the &lt;code&gt;tofu&lt;/code&gt; directory, running &lt;code&gt;tofu init&lt;/code&gt; will install the provider&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ tofu init

Initializing the backend...

Initializing provider plugins...
- Finding bpg/proxmox versions matching &quot;0.80.0&quot;...
- Installing bpg/proxmox v0.80.0...
- Installed bpg/proxmox v0.80.0 (signed, key ID F0582AD6AE97C188)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And a &lt;code&gt;tofu plan&lt;/code&gt; shouldn&apos;t return an error&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ tofu plan

No changes. Your infrastructure matches the configuration.

OpenTofu has compared your real infrastructure against your configuration and found no
differences, so no changes are needed.
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Removing sensitive information&lt;/h3&gt;
&lt;p&gt;If you made it so far, you probably think I am completely reckless for storing credentials in plain text files, and you would be correct to think so. Credentials should never be stored in plain text. Fortunately opentofu can grab sensitive credentials from environment variables.&lt;/p&gt;
&lt;p&gt;I use Bitwarden to store my production credentials and pass them to opentofu when I step into my work directory. You can find all the details on how to do it on &lt;a href=&quot;/posts/2025/07/28-direnv-bitwarden-integration/&quot;&gt;this previous blog post&lt;/a&gt;. Bear in mind that this works well for a homelab but I wouldn&apos;t recommend it for a production setup.&lt;/p&gt;
&lt;p&gt;We need to create a new credential in the &lt;code&gt;Infra&lt;/code&gt; folder of our vault, and call it &lt;code&gt;PROXMOX_VE_API_TOKEN&lt;/code&gt;. Its content is the following&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;terraform@pve!provider=yourApiKeyGoesHere
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we need to sync the vault managed by the bitwarden CLI, to ensure it has the credential we just added.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ bw sync
Syncing complete.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&apos;s update our &lt;code&gt;~/Projects/infra/.direnv&lt;/code&gt; to make it retrieve the &lt;code&gt;PROXMOX_VE_API_TOKEN&lt;/code&gt; environment variable when we step into our work directory.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bitwarden_password_to_env Infra PROXMOX_VE_API_TOKEN
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And let&apos;s make direnv allow it&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ direnv allow ~/Projects/infra/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can now remove the credential from &lt;code&gt;tofu/providers.tf&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;provider &quot;proxmox&quot; {
  endpoint  = &quot;https://192.168.1.220:8006/&quot;
  api_token = &quot;terraform@pve!provider=REDACTED&quot;
  insecure  = true

  ssh {
    agent    = true
    username = &quot;root&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Spinning up a new VM&lt;/h3&gt;
&lt;p&gt;Now I have a working proxmox provider for opentofu, it&apos;s time to spin up a first VM! I already use Debian for my Proxmox host, I&apos;m familiar with Debian, it&apos;s very stable, and it has a reactive security team. I want to keep track of as few operating systems (OS) as possible, so whenever possible I will use it as the base OS for my VMs.&lt;/p&gt;
&lt;p&gt;When I spin up a new VM, I can also pre-configure a few settings with &lt;a href=&quot;https://cloud-init.io/&quot;&gt;cloud-init&lt;/a&gt;. Cloud-init defines standard files that my VM will read on first boot. Those files contain various instructions: I can use them to give a static IP to my VM, create a user, add it to the sudoers without a password, and add a ssh key to let me perform key-based authentication with ssh.&lt;/p&gt;
&lt;p&gt;I need to use a &quot;cloud image&quot; of Debian for it to support cloud-init file. I can grab the link on &lt;a href=&quot;https://www.debian.org/distrib/&quot;&gt;Debian&apos;s official Download page&lt;/a&gt;. I could upload it manually to Proxmox, but we&apos;re here to make things tidy and reproducible! So let&apos;s create a &lt;code&gt;tofu/cloud-images.tf&lt;/code&gt; file where we will tell opentofu to ask the Proxmox node to download the file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_download_file&quot; &quot;debian_13_cloud_image&quot; {
  content_type = &quot;iso&quot;
  datastore_id = &quot;local&quot;
  node_name    = &quot;proximighty&quot;
  url          = &quot;https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2&quot;
  file_name    = &quot;debian-13-generic-amd64.img&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!info] No include needed!&lt;/p&gt;
&lt;p&gt;Opentofu merges all the files in the root of a directory into a single file before processing it. There is no need to include/import our &lt;code&gt;tofu/providers.tf&lt;/code&gt; file into &lt;code&gt;tofu/cloud-images.tf&lt;/code&gt;!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Let&apos;s run a &lt;code&gt;tofu plan&lt;/code&gt; to see what opentofu would do.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ tofu plan
OpenTofu used the selected providers to generate the following execution plan. Resource actions
are indicated with the following symbols:
  + create

OpenTofu will perform the following actions:

  # proxmox_virtual_environment_download_file.debian_13_cloud_image will be created
  + resource &quot;proxmox_virtual_environment_download_file&quot; &quot;debian_13_cloud_image&quot; {
      + content_type        = &quot;iso&quot;
      + datastore_id        = &quot;local&quot;
      + file_name           = &quot;debian-13-generic-amd64.img&quot;
      + id                  = (known after apply)
      + node_name           = &quot;proximighty&quot;
      + overwrite           = true
      + overwrite_unmanaged = false
      + size                = (known after apply)
      + upload_timeout      = 600
      + url                 = &quot;https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2&quot;
      + verify              = true
    }

Plan: 1 to add, 0 to change, 0 to destroy.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Everything looks alright, let&apos;s apply it to actually make the Proxmox host download the image!&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ tofu apply

OpenTofu used the selected providers to generate the following execution plan. Resource actions
are indicated with the following symbols:
  + create

OpenTofu will perform the following actions:

  # proxmox_virtual_environment_download_file.debian_13_cloud_image will be created
  + resource &quot;proxmox_virtual_environment_download_file&quot; &quot;debian_13_cloud_image&quot; {
      + content_type        = &quot;iso&quot;
      + datastore_id        = &quot;local&quot;
      + file_name           = &quot;debian-13-generic-amd64.img&quot;
      + id                  = (known after apply)
      + node_name           = &quot;proximighty&quot;
      + overwrite           = true
      + overwrite_unmanaged = false
      + size                = (known after apply)
      + upload_timeout      = 600
      + url                 = &quot;https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2&quot;
      + verify              = true
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  OpenTofu will perform the actions described above.
  Only &apos;yes&apos; will be accepted to approve.

  Enter a value: yes

proxmox_virtual_environment_download_file.debian_13_cloud_image: Creating...
proxmox_virtual_environment_download_file.debian_13_cloud_image: Creation complete after 8s [id=local:iso/debian-13-generic-amd64.img]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Looking at the Proxmox UI I can see that the image has indeed been downloaded&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;Excellent! Now we can describe the parameters of the virtual machine we wan to create by creating a &lt;code&gt;tofu/k3s-main.tf&lt;/code&gt; file that contains a &lt;code&gt;virtual_environment_vm&lt;/code&gt; resource like so&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot; {
  name        = &quot;k3s-main&quot;
  description = &quot;Production k3s&apos; main VM&quot;
  tags        = [&quot;production&quot;, &quot;k3s&quot;, &quot;debian&quot;]
  node_name   = &quot;proximighty&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is the meta-data of our VM, giving it a name and a Proxmox node to run onto. But we need to be more specific. Let&apos;s give it 4 CPUs, and 16 GB of RAM.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot; {
  name        = &quot;k3s-main&quot;
  description = &quot;Production k3s&apos; main VM&quot;
  tags        = [&quot;production&quot;, &quot;k3s&quot;, &quot;debian&quot;]
  node_name   = &quot;proximighty&quot;

  cpu {
    cores = 4
    type  = &quot;x86-64-v4&quot;
  }

  memory {
    dedicated = 16384
    floating  = 16384 # set equal to dedicated to enable ballooning
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To figure out the type of cpu to use for your VM, issue the following command on the Proxmox host&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ /lib64/ld-linux-x86-64.so.2 --help
[...]
Subdirectories of glibc-hwcaps directories, in priority order:
  x86-64-v4 (supported, searched)
  x86-64-v3 (supported, searched)
  x86-64-v2 (supported, searched)
[...]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can then give it a 50 GB disk with the &lt;code&gt;disk&lt;/code&gt; block.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot; {
  name        = &quot;k3s-main&quot;

[...]

  disk {
    datastore_id = &quot;local&quot;
    interface    = &quot;virtio0&quot;
    iothread     = true
    size         = 50
    file_id      = proxmox_virtual_environment_download_file.debian_13_cloud_image.id
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I use the &lt;code&gt;local&lt;/code&gt; datastore and not &lt;code&gt;lvm-thin&lt;/code&gt; despite running QEMU because I don&apos;t want to allow over-provisioning. &lt;code&gt;lvm-thin&lt;/code&gt; would allow me to allocate a disk of 500 GB to my VM, even if I only have 100 GB available, because the VM will only fill the Proxmox drive with the actual content it uses. You can read more about storage on &lt;a href=&quot;https://pve.proxmox.com/wiki/Storage&quot;&gt;Proxmox&apos;s wiki&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I use a &lt;code&gt;virtio&lt;/code&gt; device, since a colleague told me &quot;virtio uses a special communication channel that requires guest drivers, that are well supported out of the box on Linux. You can be way faster when the guest knows it&apos;s a VM and don&apos;t have to emulate something that was intended for actual real hardware. It&apos;s the same for your network interface and a bunch of other things. &lt;strong&gt;Usually if there is a virtio option you want to use that&lt;/strong&gt;&quot;&lt;/p&gt;
&lt;p&gt;I set the &lt;code&gt;file_id&lt;/code&gt; to the Debian cloud image we downloaded earlier.&lt;/p&gt;
&lt;p&gt;I can then add a network interface that will use the &lt;code&gt;vmbr0&lt;/code&gt; bridge I created when setting up my Proxmox host. I also need an empty &lt;code&gt;serial_device&lt;/code&gt;, or Debian crashes.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot; {
  name        = &quot;k3s-main&quot;

[...]

  network_device {
    bridge = &quot;vmbr0&quot;
  }

  serial_device {}
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, instead of spinning up an un-configured VM and manually retrieving the parameters set during boot, we will use cloud-init to pre-configure it. We will do the following:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Configure the network to get a static IP&lt;/li&gt;
&lt;li&gt;Configure the hostname&lt;/li&gt;
&lt;li&gt;Configure the timezone to UTC&lt;/li&gt;
&lt;li&gt;Add a user &lt;code&gt;thib&lt;/code&gt; that &lt;em&gt;doesn&apos;t&lt;/em&gt; have a password&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;thib&lt;/code&gt; to sudoers, without a password&lt;/li&gt;
&lt;li&gt;Add my the public ssh key from my laptop to the trust keys of &lt;code&gt;thib&lt;/code&gt; on the VM, so I can login with a ssh key and never have to use a password.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The documentation of the Proxmox provider teach us that that &lt;a href=&quot;https://search.opentofu.org/provider/bpg/proxmox/latest/docs/guides/cloud-init#native-proxmox-cloud-init-support&quot;&gt;Proxmox has native support for cloud-init&lt;/a&gt;. This cloud-init configuration is done in the &lt;code&gt;initialization&lt;/code&gt; block of the &lt;a href=&quot;https://search.opentofu.org/provider/bpg/proxmox/latest/docs/resources/virtual_environment_vm&quot;&gt;&lt;code&gt;virtual_environment_vm&lt;/code&gt;&lt;/a&gt; resource.&lt;/p&gt;
&lt;p&gt;We will first give it an IP&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot; {
  name        = &quot;k3s-main&quot;

[...]

  initialization {
    datastore_id = &quot;local&quot;

    ip_config {
      ipv4 {
        address = &quot;192.168.1.221/24&quot;
        gateway = &quot;192.168.1.254&quot;
      }
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&apos;m only specifying the &lt;code&gt;datastore_id&lt;/code&gt; because by default it uses &lt;code&gt;local-lvm&lt;/code&gt;, which I have not configured on my Proxmox host.&lt;/p&gt;
&lt;p&gt;To create a user and give it ssh keys I could use the &lt;code&gt;user_account&lt;/code&gt; block inside &lt;code&gt;initialization&lt;/code&gt;. Unfortunately it doesn&apos;t support adding the user to sudoers, nor installing extra packages. To circumvent that limitation I will have to &lt;a href=&quot;https://cloudinit.readthedocs.io/en/latest/explanation/format.html#cloud-config-data&quot;&gt;create a user config data file&lt;/a&gt; and pass it to cloud-init.&lt;/p&gt;
&lt;p&gt;Let&apos;s start by creating the user config data file resource within &lt;code&gt;tofu/k3s-main.tf&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_file&quot; &quot;user_data_cloud_config&quot; {
  content_type = &quot;snippets&quot;
  datastore_id = &quot;local&quot;
  node_name    = &quot;proximighty&quot;

  source_raw {
    data = &amp;lt;&amp;lt;-EOF
    #cloud-config
    hostname: mightykube
    timezone: UTC
    users:
      - default
      - name: thib
        lock_passwd: true
        groups:
          - sudo
        shell: /bin/bash
        ssh_authorized_keys:
          - ${trimspace(&quot;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIGC++vbMTrSbQFKFgthj9oLaW1z5fCkQtlPCnG6eObB thib@ergaster.org&quot;)}
        sudo: ALL=(ALL) NOPASSWD:ALL
      - name: root
        lock_passwd: true
    package_update: true
    package_upgrade: true
    packages:
      - htop
      - qemu-guest-agent
      - vim
    runcmd:
      - systemctl enable qemu-guest-agent
      - systemctl start qemu-guest-agent
      - echo &quot;done&quot; &amp;gt; /tmp/cloud-config.done
    EOF

    file_name = &quot;user-data-cloud-config.yaml&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It&apos;s a bit inelegant to keep a copy of my ssh key inside this file. Let&apos;s ask opentofu to read it from the actual file on my laptop instead by creating a &lt;code&gt;local_file&lt;/code&gt; resource for it, in &lt;code&gt;tofu/k3s.tf&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;data &quot;local_file&quot; &quot;ssh_public_key&quot; {
  filename = &quot;/Users/thibaultmartin/.ssh/id_ed25519.pub&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If I try to plan the change, I get the following error&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ tofu plan
╷
│ Error: Inconsistent dependency lock file
│ 
│ The following dependency selections recorded in the lock file are inconsistent with the
│ current configuration:
│   - provider registry.opentofu.org/hashicorp/local: required by this configuration but no version is selected
│ 
│ To update the locked dependency selections to match a changed configuration, run:
│   tofu init -upgrade
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Like the error message says, I can fix it with &lt;code&gt;tofu init&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ tofu init -upgrade
Initializing the backend...

Initializing provider plugins...
- Finding opentofu/cloudflare versions matching &quot;5.7.1&quot;...
- Finding bpg/proxmox versions matching &quot;0.80.0&quot;...
- Finding latest version of hashicorp/local...
- Installing hashicorp/local v2.5.3...
- Installed hashicorp/local v2.5.3 (signed, key ID 0C0AF313E5FD9F80)
- Using previously-installed opentofu/cloudflare v5.7.1
- Using previously-installed bpg/proxmox v0.80.0

Providers are signed by their developers.
If you&apos;d like to know more about provider signing, you can read about it here:
https://opentofu.org/docs/cli/plugins/signing/

OpenTofu has made some changes to the provider dependency selections recorded
in the .terraform.lock.hcl file. Review those changes and commit them to your
version control system if they represent changes you intended to make.

OpenTofu has been successfully initialized!
[...]
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I can now change the &lt;code&gt;user_data_cloud_config&lt;/code&gt; resource to reference &lt;code&gt;ssh_public_key&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_file&quot; &quot;user_data_cloud_config&quot; {
[...]
        ssh_authorized_keys:
          - ${trimspace(&quot;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIGC++vbMTrSbQFKFgthj9oLaW1z5fCkQtlPCnG6eObB thib@ergaster.org&quot;)}
          - ${trimspace(data.local_file.ssh_public_key.content)}
[...]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now let&apos;s update the &lt;code&gt;initialization&lt;/code&gt; block of &lt;code&gt;k3s-main&lt;/code&gt; to use that cloud-init file&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot; {
  name        = &quot;k3s-main&quot;

[...]

  initialization {
    datastore_id = &quot;local&quot;

    ip_config {
      ipv4 {
        address = &quot;192.168.1.221/24&quot;
        gateway = &quot;192.168.1.254&quot;
      }
    }

    user_data_file_id = proxmox_virtual_environment_file.user_data_cloud_config.id
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can check with &lt;code&gt;tofu plan&lt;/code&gt; that everything is alright, and then actually apply the plan with &lt;code&gt;tofu apply&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ tofu apply
data.local_file.ssh_public_key: Reading...
data.local_file.ssh_public_key: Read complete after 0s [id=930cea05ae5e662573618e0d9f3e03920196cc5f]
proxmox_virtual_environment_file.user_data_cloud_config: Refreshing state... [id=local:snippets/user-data-cloud-config.yaml]
proxmox_virtual_environment_download_file.debian_13_cloud_image: Refreshing state... [id=local:iso/debian-13-generic-amd64.img]

OpenTofu used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
  + create

OpenTofu will perform the following actions:

  # proxmox_virtual_environment_vm.k3s-main will be created
  + resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot; {
    [...]
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  OpenTofu will perform the actions described above.
  Only &apos;yes&apos; will be accepted to approve.

  Enter a value: yes

proxmox_virtual_environment_vm.k3s-main: Creating...
proxmox_virtual_environment_vm.k3s-main: Creation complete after 5s [id=100]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Looking at Proxmox&apos;s console, I can see that the VM was created, it healthy, and I can even see that it has the &lt;code&gt;mightykube&lt;/code&gt; hostname I had created for it.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;Now I can try to ssh into the newly created VM from my laptop&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ssh thib@192.168.1.221
The authenticity of host &apos;192.168.1.221 (192.168.1.221)&apos; can&apos;t be established.
ED25519 key fingerprint is SHA256:39Qocnshj+JMyt4ABpD9ZIjDpOHhXqdet94QeSh+uDo.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added &apos;192.168.1.221&apos; (ED25519) to the list of known hosts.
Linux mightykube 6.12.41+deb13-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.41-1 (2025-08-12) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
thib@mightykube:~$
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&apos;s check that I can perform actions as root without being prompted for a password&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;thib@mightykube:~$ sudo apt update
Get:1 file:/etc/apt/mirrors/debian.list Mirrorlist [30 B]
Get:2 file:/etc/apt/mirrors/debian-security.list Mirrorlist [39 B]
Hit:3 https://deb.debian.org/debian trixie InRelease
Hit:4 https://deb.debian.org/debian trixie-updates InRelease
Hit:5 https://deb.debian.org/debian trixie-backports InRelease
Hit:6 https://deb.debian.org/debian-security trixie-security InRelease
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
All packages are up to date.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Brilliant! Just like that, I have a VM on Proxmox, with a static IP address, a well-known user, ssh key authentication and no password to manage at all!&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!warning] No password means no password!&lt;/p&gt;
&lt;p&gt;When creating the VM, cloud-init creates a user but doesn&apos;t give it a password. It means we can only rely on SSH to control the VM. If we lose our SSH key or mess up with the sshd config and can&apos;t ssh into the VM, we&apos;re (kind of) locked out!&lt;/p&gt;
&lt;p&gt;We have access to the VM console via Proxmox, but without a password we can&apos;t log into it. It is possible to rescue it by booting a live system and chrooting into our actual system, but it can be tedious. We&apos;ll cover that in a future blog post.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;We still have credentials in our &lt;code&gt;.tf&lt;/code&gt; files so we can&apos;t commit them yet. We will extract the credentials a bit later, but first let&apos;s refactor our files for clarity.&lt;/p&gt;
&lt;h3&gt;A single place to attribute IPs&lt;/h3&gt;
&lt;p&gt;Since all my VMs will get a static IP, I want to make sure I keep a tidy list of all the IPs already used. This will help avoid IP clashes. Let&apos;s create a new &lt;code&gt;ips.tf&lt;/code&gt; file to keep track of everything&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;locals {
  reserved_ips = {
    proxmox_host = &quot;192.168.1.220/24&quot;
    k3s_main     = &quot;192.168.1.221/24&quot;
    gateway      = &quot;192.168.1.254&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When spinning up the VM for the main k3s node, I will be able to refer to the &lt;code&gt;local.reserved_ips.k3s_main&lt;/code&gt; local variable. So let&apos;s update the &lt;code&gt;tofu/k3s-main.tf&lt;/code&gt; file accordingly!&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot; {
  name        = &quot;k3s-main&quot;
  description = &quot;Production k3s&apos; main VM&quot;
  tags        = [&quot;production&quot;, &quot;k3s&quot;, &quot;debian&quot;]
  node_name   = &quot;proximighty&quot;

  [...]

  initialization {
    datastore_id = &quot;local&quot;

    ip_config {
      ipv4 {
        address = &quot;192.168.1.221/24&quot;
        gateway = &quot;192.168.1.254&quot;
        address = local.reserved_ips.k3s_main
        gateway = local.reserved_ips.gateway
      }
    }

    user_data_file_id = proxmox_virtual_environment_file.user_data_cloud_config.id
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We now have a single file to allocate IPs to virtual machines. We can see at a glance whether an IP is already used or not. That should save us some trouble! Let&apos;s now have a look at the precautions we need to take to save our files with git.&lt;/p&gt;
&lt;h2&gt;Keeping a safe copy of our state&lt;/h2&gt;
&lt;h3&gt;What is tofu state&lt;/h3&gt;
&lt;p&gt;We used opentofu to describe what resources we wanted to create. Let&apos;s remove the &lt;code&gt;resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot;&lt;/code&gt; we have created, and run &lt;code&gt;tofu plan&lt;/code&gt; to see how opentofu would react to that.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ tofu plan
data.local_file.ssh_public_key: Reading...
data.local_file.ssh_public_key: Read complete after 0s [id=930cea05ae5e662573618e0d9f3e03920196cc5f]
proxmox_virtual_environment_download_file.debian_13_cloud_image: Refreshing state... [id=local:iso/debian-13-generic-amd64.img]
proxmox_virtual_environment_file.user_data_cloud_config: Refreshing state... [id=local:snippets/user-data-cloud-config.yaml]
proxmox_virtual_environment_vm.k3s-main: Refreshing state... [id=100]

OpenTofu used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
  - destroy

OpenTofu will perform the following actions:

  # proxmox_virtual_environment_vm.k3s-main will be destroyed
  # (because proxmox_virtual_environment_vm.k3s-main is not in configuration)
  - resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot; {
    [...]
    }

Plan: 0 to add, 0 to change, 1 to destroy.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If I remove a resource block, opentofu will try to delete it. But it might not be aware of other VMs I could have deployed. Hang on but that might be dangerous! If I already had 3 VMs running on Proxmox and started using opentofu after that, would it destroy them all, since I didn&apos;t describe them in my files?!&lt;/p&gt;
&lt;p&gt;Fortunately for us, no. Opentofu needs to know what it is in charge of, and leave the rest alone. When I provision something via opentofu, it adds it to a local inventory of all the things it manages. That inventory is called a state file and looks like the following (prettified via &lt;code&gt;jq&lt;/code&gt;)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;version&quot;: 4,
  &quot;terraform_version&quot;: &quot;1.10.5&quot;,
  &quot;serial&quot;: 13,
  &quot;lineage&quot;: &quot;24d431ee-3da9-4407-b649-b0d2c0ca2d67&quot;,
  &quot;outputs&quot;: {},
  &quot;resources&quot;: [
    {
      &quot;mode&quot;: &quot;data&quot;,
      &quot;type&quot;: &quot;local_file&quot;,
      &quot;name&quot;: &quot;ssh_public_key&quot;,
      &quot;provider&quot;: &quot;provider[\&quot;registry.opentofu.org/hashicorp/local\&quot;]&quot;,
      &quot;instances&quot;: [
        {
          &quot;schema_version&quot;: 0,
          &quot;attributes&quot;: {
            &quot;content&quot;: &quot;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIGC++vbMTrSbQFKFgthj9oLaW1z5fCkQtlPCnG6eObB thib@ergaster.org\n&quot;,
            &quot;content_base64&quot;: &quot;c3NoLWVkMjU1MTkgQUFBQUMzTnphQzFsWkRJMU5URTVBQUFBSUlHQysrdmJNVHJTYlFGS0ZndGhqOW9MYVcxejVmQ2tRdGxQQ25HNmVPYkIgdGhpYkBlcmdhc3Rlci5vcmcK&quot;,
            &quot;content_base64sha256&quot;: &quot;YjQvgHA99AXWCaKLep6phGgdlmkZHvXU3OOhRSsQvms=&quot;,
            &quot;content_base64sha512&quot;: &quot;tRp4/iG90wX0R1SghdvXwND8Hg6ADNuMMdPXANUYDa2uIjkRkLRgK5YPK6ACz5cbW+SbqvGPGzYpWNNFLGIFpQ==&quot;,
            &quot;content_md5&quot;: &quot;ed5ee6428ea7c048fe8019bb1a2206b3&quot;,
            &quot;content_sha1&quot;: &quot;930cea05ae5e662573618e0d9f3e03920196cc5f&quot;,
            &quot;content_sha256&quot;: &quot;62342f80703df405d609a28b7a9ea984681d9669191ef5d4dce3a1452b10be6b&quot;,
            &quot;content_sha512&quot;: &quot;b51a78fe21bdd305f44754a085dbd7c0d0fc1e0e800cdb8c31d3d700d5180dadae22391190b4602b960f2ba002cf971b5be49baaf18f1b362958d3452c6205a5&quot;,
            &quot;filename&quot;: &quot;/Users/thibaultmartin/.ssh/id_ed25519.pub&quot;,
            &quot;id&quot;: &quot;930cea05ae5e662573618e0d9f3e03920196cc5f&quot;
          },
          &quot;sensitive_attributes&quot;: []
        }
      ]
    },
    [...]
  ],
  &quot;check_results&quot;: null
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The tofu state is a local representation of what opentofu manages. It&apos;s absolutely mandatory for opentofu to work: this is how opentofu knows if it needs to deploy, update, or tear down resources. So we need to keep in a safe place, and my laptop is not a safe place at all. It can fail or get stolen. Since the state is a text based file, I could use git to keep remote copies of it.&lt;/p&gt;
&lt;p&gt;But as you can see, the tofu state file contains my public key, that it read from a local file. The state file contains a structured view of what is in the &lt;code&gt;.tf&lt;/code&gt; files it manages. So far we have not added any sensitive credentials, but we might do it and not realize they will end up in state, and thus on a git repo.&lt;/p&gt;
&lt;p&gt;Fortunately, opentofu comes with tools that let us encrypt the state, so we can commit it to a remote git repository with more peace of mind.&lt;/p&gt;
&lt;h3&gt;Encrypting the tofu state&lt;/h3&gt;
&lt;p&gt;Before encrypting our state, the opentofu documentation has &lt;a href=&quot;https://opentofu.org/docs/language/state/encryption/#general-guidance-and-pitfalls-please-read&quot;&gt;an important section to read&lt;/a&gt; so you understand what it entails.&lt;/p&gt;
&lt;p&gt;We need to migrate our unencrypted plan to an encrypted one. Let&apos;s bear in mind that there&apos;s no way back if we screw up, so let&apos;s make a backup first (and delete it when we&apos;re done). Note that a properly encrypted state can be migrated to a decrypted one. A botched encrypted state will likely be irrecoverable. Let&apos;s just copy it in a different directory&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Project/infra/tofu
$ mkdir ~/tfbackups
$ cp terraform.tfstate{,.backup} ~/tfbackups/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To encrypt our state, we need to choose an encryption method: as a single admin homelabber I&apos;m going for the simpler and sturdier method. I don&apos;t want to depend on extra infrastructure for secrets management, so I&apos;m using &lt;a href=&quot;https://opentofu.org/docs/language/state/encryption/#pbkdf2&quot;&gt;PBKDF2&lt;/a&gt;, which roughly means &quot;generating an encryption key from a long passphrase.&quot;&lt;/p&gt;
&lt;p&gt;With that in mind, let&apos;s follow &lt;a href=&quot;https://opentofu.org/docs/language/state/encryption/#pre-existing-project&quot;&gt;the documentation to migrate a pre-existing project&lt;/a&gt;. Let&apos;s open our &lt;code&gt;providers.tf&lt;/code&gt; file and add an &lt;code&gt;encryption&lt;/code&gt; block within the &lt;code&gt;terraform&lt;/code&gt; one.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;terraform {

  encryption {
    method &quot;unencrypted&quot; &quot;migrate&quot; {}

    key_provider &quot;pbkdf2&quot; &quot;password_key&quot; {
      passphrase = &quot;REDACTED&quot;
    }

    method &quot;aes_gcm&quot; &quot;password_based&quot; {
      keys = key_provider.pbkdf2.password_key
    }

    state {
      method = method.aes_gcm.password_based

      fallback {
        method = method.unencrypted.migrate
      }
    }
  }

  required_providers {
    proxmox = {
      source  = &quot;bpg/proxmox&quot;
      version = &quot;0.80.0&quot;
    }
  }
}

provider &quot;proxmox&quot; {
  endpoint = &quot;https://192.168.1.220:8006/&quot;

  ssh {
    agent    = true
    username = &quot;root&quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This block instructs terraform to encrypt the state with a key generated from our password. It also tells it to expect a pre-existing unencrypted state to exist, that it&apos;s okay to read and encrypt it.&lt;/p&gt;
&lt;p&gt;Note that I&apos;ve used the encryption passphrase directly in that block. We will move it to a safer place later, but for now let&apos;s keep things simple.&lt;/p&gt;
&lt;p&gt;Let&apos;s now apply this plan to see if our state gets encrypted correctly, but &lt;strong&gt;make sure you do have a cleartext backup first.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/tofu
$ tofu apply
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After the apply, we can have a look at the &lt;code&gt;terraform.tfstate&lt;/code&gt; file to check that it has indeed been encrypted.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;serial&quot;: 13,
  &quot;lineage&quot;: &quot;24d431ee-3da9-4407-b649-b0d2c0ca2d67&quot;,
  &quot;meta&quot;: {
    &quot;key_provider.pbkdf2.password_key&quot;: &quot;eyJzYWx0[...]&quot;
  },
  &quot;encrypted_data&quot;: &quot;ONXZsJhz[...]&quot;,
  &quot;encryption_version&quot;: &quot;v0&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I know that opentofu people probably know what they&apos;re doing, but I don&apos;t like that &lt;code&gt;password_key&lt;/code&gt; field. It starts with &lt;code&gt;eyJ&lt;/code&gt;, &lt;a href=&quot;/til/base64-encoded-json/&quot;&gt;so that must be a base64 encoded json object&lt;/a&gt;. Let&apos;s decode that&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ echo &quot;eyJzYWx0[...]&quot; | base64 -d
{&quot;salt&quot;:&quot;jzGRZLVANeFJpJRxj8RXg48FfOoB++GF/Honm6sIF9Y=&quot;,&quot;iterations&quot;:600000,&quot;hash_function&quot;:&quot;sha512&quot;,&quot;key_length&quot;:32}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;All good, it&apos;s just the salt, iterations, hash function and key length parameters. Those are pretty much public, we can commit the file to our repo! But... what about the &lt;code&gt;terraform.tfstate.backup&lt;/code&gt; file? Let&apos;s examine this one&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;version&quot;: 4,
  &quot;terraform_version&quot;: &quot;1.10.5&quot;,
  &quot;serial&quot;: 12,
  &quot;lineage&quot;: &quot;24d431ee-3da9-4407-b649-b0d2c0ca2d67&quot;,
  &quot;outputs&quot;: {},
  &quot;resources&quot;: [
    {
      &quot;mode&quot;: &quot;data&quot;,
      &quot;type&quot;: &quot;local_file&quot;,
      &quot;name&quot;: &quot;ssh_public_key&quot;,
      &quot;provider&quot;: &quot;provider[\&quot;registry.opentofu.org/hashicorp/local\&quot;]&quot;,
      &quot;instances&quot;: [
        {
          &quot;schema_version&quot;: 0,
          &quot;attributes&quot;: {
            &quot;content&quot;: &quot;ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIGC++vbMTrSbQFKFgthj9oLaW1z5fCkQtlPCnG6eObB thib@ergaster.org\n&quot;,
            [...]
        }
      ]
    },
	[...]
  ],
  &quot;check_results&quot;: null
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Oh dear! That one is &lt;em&gt;not&lt;/em&gt; encrypted! I didn&apos;t find any utility for it since terraform can&apos;t do &quot;rollbacks&quot;, and I couldn&apos;t find docs for it. I&apos;ve deleted the file and I could still perform &lt;code&gt;tofu apply&lt;/code&gt; without a problem. The next iterations should be encrypted, but I will add it to my &lt;code&gt;.gitignore&lt;/code&gt; just in case!&lt;/p&gt;
&lt;p&gt;We&apos;re not &lt;em&gt;quite&lt;/em&gt; ready to commit our files though. We still have a secret in plain text! We give away the encryption key we use. Let&apos;s extract it into an environment variable so we don&apos;t leak it.&lt;/p&gt;
&lt;h3&gt;Removing sensitive information&lt;/h3&gt;
&lt;p&gt;We need to create a new credential in the &lt;code&gt;Infra&lt;/code&gt; folder of our vault, and call it &lt;code&gt;TF_ENCRYPTION&lt;/code&gt;. Its content is the following&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;key_provider &quot;pbkdf2&quot; &quot;password_key&quot; { passphrase = &quot;yourPassphraseGoesHere&quot; }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we need to sync the vault managed by the bitwarden CLI, to ensure it has the credential we just added.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ bw sync
Syncing complete.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&apos;s update our &lt;code&gt;~/Projects/infra/.direnv&lt;/code&gt; to make it retrieve the &lt;code&gt;TF_ENCRYPTION&lt;/code&gt; environment variable&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;bitwarden_password_to_env Infra PROXMOX_VE_API_TOKEN TF_ENCRYPTION
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And let&apos;s make direnv allow it&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ direnv allow ~/Projects/infra/
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&apos;s remove the block that provided our password from the &lt;code&gt;encryption&lt;/code&gt; block in &lt;code&gt;providers.tf&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;terraform {

  encryption {
    method &quot;unencrypted&quot; &quot;migrate&quot; {}

    key_provider &quot;pbkdf2&quot; &quot;password_key&quot; {
      passphrase = &quot;REDACTED&quot;
    }

    method &quot;aes_gcm&quot; &quot;password_based&quot; {
      keys = key_provider.pbkdf2.password_key
    }

    state {
      method = method.aes_gcm.password_based

      fallback {
        method = method.unencrypted.migrate
      }
    }
  }

[...]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And let&apos;s try a &lt;code&gt;tofu plan&lt;/code&gt; to confirm that opentofu could read the passphrase from the environment variable&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ tofu plan
╷
│ Warning: Unencrypted method configured
│ 
│   on  line 0:
│   (source code not available)
│ 
│ Method unencrypted is present in configuration. This is a security risk and
│ should only be enabled during migrations.
╵
data.local_file.ssh_public_key: Reading...
data.local_file.ssh_public_key: Read complete after 0s [id=930cea05ae5e662573618e0d9f3e03920196cc5f]
proxmox_virtual_environment_download_file.debian_13_cloud_image: Refreshing state... [id=local:iso/debian-13-generic-amd64.img]
proxmox_virtual_environment_file.user_data_cloud_config: Refreshing state... [id=local:snippets/user-data-cloud-config.yaml]
proxmox_virtual_environment_vm.k3s-main: Refreshing state... [id=100]

No changes. Your infrastructure matches the configuration.

OpenTofu has compared your real infrastructure against your configuration and found
no differences, so no changes are needed.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Brilliant! We can now also remove the &lt;code&gt;migrate&lt;/code&gt; and &lt;code&gt;fallback blocks&lt;/code&gt; so opentofu doesn&apos;t trust unencrypted content at all, which will prevent malicious actors from tampering with our file.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;terraform {

  encryption {
    method &quot;unencrypted&quot; &quot;migrate&quot; {}

    method &quot;aes_gcm&quot; &quot;password_based&quot; {
      keys = key_provider.pbkdf2.password_key
    }

    state {
      method = method.aes_gcm.password_based

      fallback {
        method = method.unencrypted.migrate
      }
    }
  }

[...]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally we can delete our cleartext backup&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ rm -Rf ~/tfbackups
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Voilà, we have an encrypted state that we can push to a remote Github repository, and our state will be reasonably safe for today&apos;s standards!&lt;/p&gt;
&lt;h2&gt;Fully configuring and managing the VM&lt;/h2&gt;
&lt;p&gt;As we&apos;ve seen when setting up the Proxmox host, ansible can be used to put a machine in a desired state. It can write a playbook to install k3s and copy the kubeconfig file to my admin laptop.&lt;/p&gt;
&lt;p&gt;Then there&apos;s the question of: how do we make opentofu (who provisions the VMs) and ansible (who deploys services on the VMs) talk to each other? In an ideal world, I would tell opentofu to provision the VM, and then to run an ansible playbook on the hosts it has created.&lt;/p&gt;
&lt;p&gt;There&apos;s an &lt;a href=&quot;https://registry.terraform.io/providers/ansible/ansible/latest/docs&quot;&gt;ansible opentofu provider&lt;/a&gt; that&apos;s supposed to play this role. I didn&apos;t find intuitive to use, and most people around me told me they found it so cumbersome they didn&apos;t use it. There is a more flexible and sturdy solution: ansible dynamic inventories!&lt;/p&gt;
&lt;h3&gt;Creating a dynamic inventory for k3s VMs&lt;/h3&gt;
&lt;p&gt;Ansible supports creating inventories by calling plugins that will retrieve information from sources. The &lt;a href=&quot;https://docs.ansible.com/ansible/latest/collections/community/general/proxmox_inventory.html&quot;&gt;Proxmox inventory source plugin&lt;/a&gt; lets ansible query Proxmox and retrieve information about VMs, and automatically group them together.&lt;/p&gt;
&lt;p&gt;Hang on. Are we really going to create a dynamic inventory &lt;em&gt;for a single VM?&lt;/em&gt; I know we&apos;re over engineering things for the sake of learning, but &lt;em&gt;isn&apos;t it a bit too much?&lt;/em&gt; As always, it&apos;s important to consider what problem we&apos;re trying to solve. To me, we&apos;re solving two different problems:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;We make sure that there is a single canonical source of truth&lt;/strong&gt;, and it is opentofu. The IP defined in opentofu, it&apos;s the one provisioned on Proxmox, and it&apos;s the one the dynamic inventory will use to perform operations on the VM. If the VM needs to change its IP, we only have to update it in opentofu, and ansible will follow along.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;We build a sane foundation for more complex setups&lt;/strong&gt;. It will be easy to extend when deploying more VMs to run complex clusters, while not adding unnecessary complexity.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So let&apos;s start by making sure we have the Proxmox plugin installed. It is part of the &lt;code&gt;community.general&lt;/code&gt; collection on &lt;code&gt;ansible-galaxy&lt;/code&gt;, so let&apos;s install it&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ansible-galaxy collection install community.general
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then in the &lt;code&gt;~/Projects/infra/ansible/inventory&lt;/code&gt; directory, we can create a &lt;code&gt;proximighty.proxmox.yaml&lt;/code&gt;. The file has to end with &lt;code&gt;.proxmox.yaml&lt;/code&gt; for the Proxmox plugin to work.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plugin: community.general.proxmox
url: https://192.168.1.200:8006
user: &quot;terraform@pve&quot;
token_id: &quot;provider&quot;
token_secret: &quot;REDACTED&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&apos;s break it down:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;plugin&lt;/code&gt; tells ansible to use the Proxmox inventory source plugin.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;url&lt;/code&gt; is the URL of the Proxmox cluster.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;user&lt;/code&gt; is the Proxmox user we authenticate as. Here I&apos;m reusing the same value as the service account we have created for opentofu.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;token_id&lt;/code&gt; is the ID of the token we have issued for the user. I&apos;m also reusing the same value as the API Key we have created for opentofu.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;token_secret&lt;/code&gt; is the password for the API Key. Here again I&apos;m reusing the same value as the API Key we have created for opentofu. I&apos;m writting it in the plain text file for now, we will clean it up later.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now we can try to pass that dynamic inventory configuration to ansible for it to build an inventory from Proxmox.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ansible-inventory -i proximighty.proxmox.yaml --list
[WARNING]:  * Failed to parse
/Users/thibaultmartin/Projects/infra/ansible/inventory/proximighty.proxmox.yaml
with auto plugin: HTTPSConnectionPool(host=&apos;192.168.1.200&apos;, port=8006): Max retries
exceeded with url: /api2/json/nodes (Caused by SSLError(SSLCertVerificationError(1,
&apos;[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local
issuer certificate (_ssl.c:1028)&apos;)))
[WARNING]:  * Failed to parse
/Users/thibaultmartin/Projects/infra/ansible/inventory/proximighty.proxmox.yaml
with yaml plugin: Plugin configuration YAML file, not YAML inventory
[WARNING]:  * Failed to parse
/Users/thibaultmartin/Projects/infra/ansible/inventory/proximighty.proxmox.yaml
with ini plugin: Invalid host pattern &apos;plugin:&apos; supplied, ending in &apos;:&apos; is not
allowed, this character is reserved to provide a port.
[WARNING]: Unable to parse
/Users/thibaultmartin/Projects/infra/ansible/inventory/proximighty.proxmox.yaml as
an inventory source
[WARNING]: No inventory was parsed, only implicit localhost is available
{
    &quot;_meta&quot;: {
        &quot;hostvars&quot;: {}
    },
    &quot;all&quot;: {
        &quot;children&quot;: [
            &quot;ungrouped&quot;
        ]
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And it fails! This is unfortunately not a surprise. We asked the plugin to look up into Proxmox and gave it a https URL. But when Proxmox runs for the first time, it generates a self-signed certificate. It is a perfectly fine certificate we can use to handle https requests. The only problem is that our laptop doesn&apos;t trust the Proxmox host, who signed the certificate for itself.&lt;/p&gt;
&lt;p&gt;The good news is that &lt;a href=&quot;https://pve.proxmox.com/wiki/Certificate_Management&quot;&gt;Proxmox can retrieve certificates&lt;/a&gt; signed by authorities our laptop trusts! The bad news is that we need to understand what we&apos;re doing to do it properly. Like earlier, when we configured the Proxmox provider for opentofu, let&apos;s ask the Proxmox plugin to use the certificate even if it doesn&apos;t trust the authority who signed it. Since we&apos;re in a homelab on a home network, the risk of accidentally reaching a host that impersonates our Proxmox host is still fairly low, so it&apos;s acceptable to temporarily take this risk here again.&lt;/p&gt;
&lt;p&gt;Let&apos;s add the following line to our dynamic inventory configuration to ignore the certificate signature&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plugin: community.general.proxmox
url: https://192.168.1.200:8006
user: &quot;terraform@pve&quot;
token_id: &quot;provider&quot;
token_secret: &quot;REDACTED&quot;
validate_certs: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And now, running the inventory command again&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/ansible/inventory
$ ansible-inventory -i proximighty.proxmox.yaml --list
{
    &quot;_meta&quot;: {
        &quot;hostvars&quot;: {}
    },
    &quot;all&quot;: {
        &quot;children&quot;: [
            &quot;ungrouped&quot;,
            &quot;proxmox_all_lxc&quot;,
            &quot;proxmox_all_qemu&quot;,
            &quot;proxmox_all_running&quot;,
            &quot;proxmox_all_stopped&quot;,
            &quot;proxmox_nodes&quot;,
            &quot;proxmox_proximighty_lxc&quot;,
            &quot;proxmox_proximighty_qemu&quot;
        ]
    },
    &quot;proxmox_all_qemu&quot;: {
        &quot;hosts&quot;: [
            &quot;k3s-main&quot;
        ]
    },
    &quot;proxmox_all_running&quot;: {
        &quot;hosts&quot;: [
            &quot;k3s-main&quot;
        ]
    },
    &quot;proxmox_nodes&quot;: {
        &quot;hosts&quot;: [
            &quot;proximighty&quot;
        ]
    },
    &quot;proxmox_proximighty_qemu&quot;: {
        &quot;hosts&quot;: [
            &quot;k3s-main&quot;
        ]
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Great! We can see that our k3s-main VM appears! We didn&apos;t learn a lot about it though. Let&apos;s ask the Proxmox plugin to give us more information about the VMs with the &lt;a href=&quot;https://docs.ansible.com/ansible/latest/collections/community/general/proxmox_inventory.html#parameter-want_facts&quot;&gt;&lt;code&gt;want_facts&lt;/code&gt;&lt;/a&gt; parameter&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plugin: community.general.proxmox
url: https://192.168.1.200:8006
user: &quot;terraform@pve&quot;
token_id: &quot;provider&quot;
token_secret: &quot;REDACTED&quot;
want_facts: true
validate_certs: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&apos;s run it again and see if we get more interesting results&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/ansible/inventory
$ ansible-inventory -i proximighty.proxmox.yaml --list
{
    &quot;_meta&quot;: {
        &quot;hostvars&quot;: {
            &quot;k3s-main&quot;: {
                &quot;proxmox_acpi&quot;: 1,
                &quot;proxmox_agent&quot;: {
                    &quot;enabled&quot;: &quot;0&quot;,
                    &quot;fstrim_cloned_disks&quot;: &quot;0&quot;,
                    &quot;type&quot;: &quot;virtio&quot;
                },
                &quot;proxmox_balloon&quot;: 16384,
                &quot;proxmox_bios&quot;: &quot;seabios&quot;,
                &quot;proxmox_boot&quot;: {
                    &quot;order&quot;: &quot;virtio0;net0&quot;
                },
                &quot;proxmox_cicustom&quot;: {
                    &quot;user&quot;: &quot;local:snippets/user-data-cloud-config.yaml&quot;
                },
                &quot;proxmox_cores&quot;: 4,
                &quot;proxmox_cpu&quot;: {
                    &quot;cputype&quot;: &quot;x86-64-v4&quot;
                },
                &quot;proxmox_cpuunits&quot;: 1024,
                &quot;proxmox_description&quot;: &quot;Production k3s&apos; main VM&quot;,
                &quot;proxmox_digest&quot;: &quot;b68508152b464627d06cba6505ed195aa3d34f59&quot;,
                &quot;proxmox_ide2&quot;: {
                    &quot;disk_image&quot;: &quot;local:100/vm-100-cloudinit.qcow2&quot;,
                    &quot;media&quot;: &quot;cdrom&quot;
                },
                &quot;proxmox_ipconfig0&quot;: {
                    &quot;gw&quot;: &quot;192.168.1.254&quot;,
                    &quot;ip&quot;: &quot;192.168.1.221/24&quot;
                },
                &quot;proxmox_keyboard&quot;: &quot;en-us&quot;,
                &quot;proxmox_memory&quot;: &quot;16384&quot;,
                &quot;proxmox_meta&quot;: {
                    &quot;creation-qemu&quot;: &quot;9.2.0&quot;,
                    &quot;ctime&quot;: &quot;1753547614&quot;
                },
                &quot;proxmox_name&quot;: &quot;k3s-main&quot;,
                &quot;proxmox_net0&quot;: {
                    &quot;bridge&quot;: &quot;vmbr0&quot;,
                    &quot;firewall&quot;: &quot;0&quot;,
                    &quot;virtio&quot;: &quot;BC:24:11:A6:96:8B&quot;
                },
                &quot;proxmox_node&quot;: &quot;proximighty&quot;,
                &quot;proxmox_numa&quot;: 0,
                &quot;proxmox_onboot&quot;: 1,
                &quot;proxmox_ostype&quot;: &quot;other&quot;,
                &quot;proxmox_protection&quot;: 0,
                &quot;proxmox_qmpstatus&quot;: &quot;running&quot;,
                &quot;proxmox_scsihw&quot;: {
                    &quot;disk_image&quot;: &quot;virtio-scsi-pci&quot;
                },
                &quot;proxmox_serial0&quot;: &quot;socket&quot;,
                &quot;proxmox_smbios1&quot;: {
                    &quot;uuid&quot;: &quot;0d47f7c8-e0b4-4302-be03-64aa931a4c4e&quot;
                },
                &quot;proxmox_snapshots&quot;: [],
                &quot;proxmox_sockets&quot;: 1,
                &quot;proxmox_status&quot;: &quot;running&quot;,
                &quot;proxmox_tablet&quot;: 1,
                &quot;proxmox_tags&quot;: &quot;debian;k3s;production&quot;,
                &quot;proxmox_tags_parsed&quot;: [
                    &quot;debian&quot;,
                    &quot;k3s&quot;,
                    &quot;production&quot;
                ],
                &quot;proxmox_template&quot;: 0,
                &quot;proxmox_virtio0&quot;: {
                    &quot;aio&quot;: &quot;io_uring&quot;,
                    &quot;backup&quot;: &quot;1&quot;,
                    &quot;cache&quot;: &quot;none&quot;,
                    &quot;discard&quot;: &quot;ignore&quot;,
                    &quot;disk_image&quot;: &quot;local:100/vm-100-disk-0.qcow2&quot;,
                    &quot;iothread&quot;: &quot;1&quot;,
                    &quot;replicate&quot;: &quot;1&quot;,
                    &quot;size&quot;: &quot;500G&quot;
                },
                &quot;proxmox_vmgenid&quot;: &quot;e00a2059-1310-4b0b-87f7-7818e7cdb9ae&quot;,
                &quot;proxmox_vmid&quot;: 100,
                &quot;proxmox_vmtype&quot;: &quot;qemu&quot;
            }
        }
    },
    &quot;all&quot;: {
        &quot;children&quot;: [
            &quot;ungrouped&quot;,
            &quot;proxmox_all_lxc&quot;,
            &quot;proxmox_all_qemu&quot;,
            &quot;proxmox_all_running&quot;,
            &quot;proxmox_all_stopped&quot;,
            &quot;proxmox_nodes&quot;,
            &quot;proxmox_proximighty_lxc&quot;,
            &quot;proxmox_proximighty_qemu&quot;
        ]
    },
    &quot;proxmox_all_qemu&quot;: {
        &quot;hosts&quot;: [
            &quot;k3s-main&quot;
        ]
    },
    &quot;proxmox_all_running&quot;: {
        &quot;hosts&quot;: [
            &quot;k3s-main&quot;
        ]
    },
    &quot;proxmox_nodes&quot;: {
        &quot;hosts&quot;: [
            &quot;proximighty&quot;
        ]
    },
    &quot;proxmox_proximighty_qemu&quot;: {
        &quot;hosts&quot;: [
            &quot;k3s-main&quot;
        ]
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s a tonne of information! Probably more than we need, and we still don&apos;t know how to connect to a specific host. Let&apos;s add some order into that. First, let&apos;s group all the VMs that have &lt;code&gt;k3s&lt;/code&gt; in their tags under a an ansible group called &lt;code&gt;k3s&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plugin: community.general.proxmox
url: https://192.168.1.200:8006
user: &quot;terraform@pve&quot;
token_id: &quot;provider&quot;
token_secret: &quot;REDACTED&quot;
want_facts: true
groups:
  k3s: &quot;&apos;k3s&apos; in (proxmox_tags_parsed|list)&quot;
validate_certs: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And now let&apos;s tell ansible how to figure out what IP to use for a host. Since we are the ones provisioning the VMs, we know for sure that we have configured them to use a static IP, on the single virtual network interface we gave them.&lt;/p&gt;
&lt;p&gt;Let&apos;s use the &lt;a href=&quot;https://docs.ansible.com/ansible/latest/collections/community/general/proxmox_inventory.html#parameter-compose&quot;&gt;&lt;code&gt;compose&lt;/code&gt;&lt;/a&gt; parameter to populate an &lt;code&gt;ansible_host&lt;/code&gt; variable that contains the IP of the VM.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plugin: community.general.proxmox
url: https://192.168.1.200:8006
user: &quot;terraform@pve&quot;
token_id: &quot;provider&quot;
token_secret: &quot;REDACT&quot;
want_facts: true
groups:
  k3s: &quot;&apos;k3s&apos; in (proxmox_tags_parsed|list)&quot;
compose:
  ansible_host: proxmox_ipconfig0.ip | default(proxmox_net0.ip) | ansible.utils.ipaddr(&apos;address&apos;)
validate_certs: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And finally let&apos;s test this again&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/ansible/inventory
ansible-inventory -i proximighty.proxmox.yaml --list
{
    &quot;_meta&quot;: {
        &quot;hostvars&quot;: {
            &quot;k3s-main&quot;: {
                &quot;ansible_host&quot;: &quot;192.168.1.221&quot;,
                &quot;proxmox_acpi&quot;: 1,
                &quot;proxmox_agent&quot;: {
                    &quot;enabled&quot;: &quot;0&quot;,
                    &quot;fstrim_cloned_disks&quot;: &quot;0&quot;,
                    &quot;type&quot;: &quot;virtio&quot;
                },
                [...]
            }
        }
    },
    &quot;all&quot;: {
        &quot;children&quot;: [
            &quot;ungrouped&quot;,
            &quot;proxmox_all_lxc&quot;,
			[...]
            &quot;k3s&quot;
        ]
    },
    &quot;k3s&quot;: {
        &quot;hosts&quot;: [
            &quot;k3s-main&quot;
        ]
    },
	[...]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Brilliant! We now have a &lt;code&gt;k3s&lt;/code&gt; group, that contains our single &lt;code&gt;k3s-main&lt;/code&gt; VM, and it&apos;s been able to retrieve its IP successfully! Let&apos;s create a simple playbook to try to execute on the VM one command that works and one that doesn&apos;t.&lt;/p&gt;
&lt;p&gt;Let&apos;s create a &lt;code&gt;~/Projects/infra/ansible/k3s/test.yaml&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
- name: Execute commands on the k3s host
  hosts: k3s
  remote_user: thib
  tasks:
    - name: Echo on the remote server
      ansible.builtin.command: echo &quot;It worked&quot;
      changed_when: false
    - name: Get k3s installed version
      ansible.builtin.command: k3s --version
      register: k3s_version_output
      changed_when: false
      ignore_errors: true
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The only two notable things here are&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;hosts&lt;/code&gt; is the name of the group we created in the dynamic inventory&lt;/li&gt;
&lt;li&gt;&lt;code&gt;remote_user&lt;/code&gt; is the user I have pre-configured via cloud-init when spinning up the VM&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;$ cd
$ ansible-playbook -i inventory/proximighty.proxmox.yaml k3s/test.yaml 

PLAY [Execute commands on the k3s host] ********************************************

TASK [Gathering Facts] *************************************************************
ok: [k3s-main]

TASK [Echo on the remote server] ***************************************************
ok: [k3s-main]

TASK [Get k3s installed version] ***************************************************
fatal: [k3s-main]: FAILED! =&amp;gt; {&quot;changed&quot;: false, &quot;cmd&quot;: &quot;k3s --version&quot;, &quot;msg&quot;: &quot;[Errno 2] No such file or directory: b&apos;k3s&apos;&quot;, &quot;rc&quot;: 2, &quot;stderr&quot;: &quot;&quot;, &quot;stderr_lines&quot;: [], &quot;stdout&quot;: &quot;&quot;, &quot;stdout_lines&quot;: []}
...ignoring

PLAY RECAP *************************************************************************
k3s-main                   : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It works! Now that we know how to build an inventory based on Proxmox tags and could make a simply ansible playbook use it, let&apos;s move forward and actually deploy k3s on our VM!&lt;/p&gt;
&lt;h3&gt;Deploying k3s on the k3s-main VM&lt;/h3&gt;
&lt;p&gt;The k3s maintainers have created a &lt;a href=&quot;https://github.com/k3s-io/k3s-ansible/&quot;&gt;k3s-ansible playbook&lt;/a&gt; that can preconfigure a machine to ensure it will be ready to make k3s run, and deploy a single or multi-node cluster. It&apos;s a great playbook and it&apos;s important not to reinvent the wheel. But I also like to understand what I execute, and keep things minimal to limit the risk of breakage.&lt;/p&gt;
&lt;p&gt;Let&apos;s take inspiration from this excellent playbook to build one tailored for our (very simple) needs: deploying k3s-server on a single node. When trying to install k3s server via the playbook on Debian, it executes 2 roles:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/k3s-io/k3s-ansible/blob/master/roles/prereq/tasks/main.yml&quot;&gt;&lt;code&gt;prereq&lt;/code&gt;&lt;/a&gt; that performs a series of checks to ensure k3s can be installed and run well&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/k3s-io/k3s-ansible/blob/master/roles/k3s_server/tasks/main.yml&quot;&gt;&lt;code&gt;k3s_server&lt;/code&gt;&lt;/a&gt; that downloads, preconfigures and install k3s&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We know that the OS powering our virtual machine is always going to be a Debian cloud image. None of the checks in &lt;code&gt;prereq&lt;/code&gt; are useful for a fresh vanilla Debian stable. So let&apos;s skip it entirely.&lt;/p&gt;
&lt;p&gt;Let&apos;s have a closer look at what &lt;code&gt;k3s_server&lt;/code&gt; does, and carry the important bits over to our playbook. We want to&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check whether k3s is already installed so we don&apos;t override it if it was already installed&lt;/li&gt;
&lt;li&gt;Download the install script&lt;/li&gt;
&lt;li&gt;Execute the install script to download k3s&lt;/li&gt;
&lt;li&gt;Create a systemd service for k3s to start automatically&lt;/li&gt;
&lt;li&gt;Enable the service&lt;/li&gt;
&lt;li&gt;Copy the kubeconfig file generated by k3s to our laptop, and merge it with our kubeconfig under the cluster name and context &lt;code&gt;mightykube&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;To do things cleanly, we will create a &lt;code&gt;k3s_server&lt;/code&gt; role in a &lt;code&gt;k3s&lt;/code&gt; directory.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
- name: Get k3s installed version
  ansible.builtin.command: k3s --version
  register: k3s_server_version_output
  changed_when: false
  ignore_errors: true

- name: Set k3s installed version
  when: not ansible_check_mode and k3s_server_version_output.rc == 0
  ansible.builtin.set_fact:
    k3s_server_installed_version: &quot;{{ k3s_server_version_output.stdout_lines[0].split(&apos; &apos;)[2] }}&quot;

- name: Download and execute k3s installer if k3s is not already installed
  when: not ansible_check_mode and (k3s_server_version_output.rc != 0 or k3s_server_installed_version is version(k3s_server_version, &apos;&amp;lt;&apos;))
  block:
    - name: Download K3s install script
      ansible.builtin.get_url:
        url: https://get.k3s.io/
        timeout: 120
        dest: /usr/local/bin/k3s-install.sh
        owner: root
        group: root
        mode: &quot;0755&quot;

    - name: Install K3s binary
      ansible.builtin.command:
        cmd: /usr/local/bin/k3s-install.sh
      environment:
        INSTALL_K3S_SKIP_START: &quot;true&quot;
        INSTALL_K3S_VERSION: &quot;{{ k3s_server_version }}&quot;
      changed_when: true

    - name: Copy K3s service file [Single]
      ansible.builtin.template:
        src: &quot;k3s-single.service.j2&quot;
        dest: &quot;/etc/systemd/system/k3s.service&quot;
        owner: root
        group: root
        mode: &quot;0644&quot;
      register: k3s_server_service_file_single

    - name: Enable and check K3s service
      ansible.builtin.systemd:
        name: k3s
        daemon_reload: true
        state: started
        enabled: true

- name: Check whether kubectl is installed on control node
  ansible.builtin.command: &quot;kubectl&quot;
  register: k3s_server_kubectl_installed
  ignore_errors: true
  delegate_to: 127.0.0.1
  become: false
  changed_when: false

# Copy the k3s config to a second file to detect changes.
# If no changes are found, we can skip copying the kubeconfig to the control node.
- name: Copy k3s.yaml to second file
  ansible.builtin.copy:
    src: /etc/rancher/k3s/k3s.yaml
    dest: /etc/rancher/k3s/k3s-copy.yaml
    mode: &quot;0600&quot;
    remote_src: true
  register: k3s_server_k3s_yaml_file_copy

- name: Apply k3s kubeconfig to control node if file has change and control node has kubectl installed
  when:
    - k3s_server_kubectl_installed.rc == 0
    - k3s_server_k3s_yaml_file_copy.changed
  block:
    - name: Copy kubeconfig to control node
      ansible.builtin.fetch:
        src: /etc/rancher/k3s/k3s.yaml
        dest: &quot;~/.kube/config.new&quot;
        flat: true

    - name: Change server address in kubeconfig on control node
      ansible.builtin.shell: |
        KUBECONFIG=~/.kube/config.new kubectl config set-cluster default --server=https://{{ hostvars[groups[&apos;k3s&apos;][0]][&apos;ansible_host&apos;] }}:6443
      delegate_to: 127.0.0.1
      become: false
      register: k3s_server_csa_result
      changed_when:
        - k3s_server_csa_result.rc == 0

    - name: Setup kubeconfig context on control node - mightykube
      ansible.builtin.replace:
        path: &quot;~/.kube/config.new&quot;
        regexp: &quot;default&quot;
        replace: &quot;mightykube&quot;
      delegate_to: 127.0.0.1
      become: false

    - name: Merge with any existing kubeconfig on control node
      ansible.builtin.shell: |
        TFILE=$(mktemp)
        KUBECONFIG=~/.kube/config.new:~/.kube/config kubectl config set-context mightykube --user=mightykube --cluster=mightykube
        KUBECONFIG=~/.kube/config.new:~/.kube/config kubectl config view --flatten &amp;gt; ${TFILE}
        mv ${TFILE} ~/.kube/config
      delegate_to: 127.0.0.1
      become: false
      register: k3s_server_mv_result
      changed_when:
        - k3s_server_mv_result.rc == 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&apos;s also create a template file for the systemd service under &lt;code&gt;~/Projects/infra/ansible/k3s/roles/k3s_server/templates&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]
Description=Lightweight Kubernetes
Documentation=https://k3s.io
Wants=network-online.target
After=network-online.target

[Install]
WantedBy=multi-user.target

[Service]
Type=notify
EnvironmentFile=-/etc/default/%N
EnvironmentFile=-/etc/sysconfig/%N
EnvironmentFile=-/etc/systemd/system/k3s.service.env
KillMode=process
Delegate=yes
# Having non-zero Limit*s causes performance problems due to accounting overhead
# in the kernel. We recommend using cgroups to do container-local accounting.
LimitNOFILE=1048576
LimitNPROC=infinity
LimitCORE=infinity
TasksMax=infinity
TimeoutStartSec=0
Restart=always
RestartSec=5s
ExecStartPre=-/sbin/modprobe br_netfilter
ExecStartPre=-/sbin/modprobe overlay
ExecStart=/usr/local/bin/k3s server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&apos;s create a &lt;code&gt;~/Projects/infra/ansible/k3s/k3s_server/default/main.yaml&lt;/code&gt; to set the version of k3s we want to install, and that we might change in the future when doing upgrades.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;k3s_server_version: &quot;v1.33.3+k3s1&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, let&apos;s create a &lt;code&gt;~/Projects/infra/ansible/k3s/deploy.yaml&lt;/code&gt; that calls the role we just created on the &lt;code&gt;k3s&lt;/code&gt; servers group.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
- name: Install k3s
  hosts: k3s
  remote_user: thib
  tasks:
    - name: Install k3s server
      ansible.builtin.import_role:
        name: k3s_server
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can now use everything together by calling the playbook we created (and the role it calls) with the dynamic inventory generated by the Proxmox plugin. Let&apos;s try!&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/ansible
$ ansible-playbook -i inventory/proximighty.proxmox.yaml k3s/deploy.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Using &lt;code&gt;kubectl&lt;/code&gt; on my laptop, I can confirm that my single node cluster is ready&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ kubectl get nodes
NAME         STATUS   ROLES                  AGE   VERSION
mightykube   Ready    control-plane,master   4m   v1.33.3+k3s1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Great! We have used ansible to automate the installation of a single node k3s cluster, and how to control it from our laptop. Thanks to our dynamic inventory, ansible also figured out what VM to install it onto automatically. Look, Mom! We have a Kubernetes at home!&lt;/p&gt;
&lt;p&gt;It&apos;s time to clean things up and remove sensitive credentials from our ansible scripts.&lt;/p&gt;
&lt;h3&gt;Removing sensitive information&lt;/h3&gt;
&lt;p&gt;When writing our ansible playbook, we didn&apos;t add new credentials. When setting up opentofu we created an API Key, and stored it in our Bitwarden vault under the name &lt;code&gt;PROXMOX_VE_API_TOKEN&lt;/code&gt;. When configuring the dynamic inventory, we reused that same API key but wrote it in the plain text file.&lt;/p&gt;
&lt;p&gt;There is a minor difference though. Opentofu uses the API Key formatted as&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;terraform@pve!provider=REDACTED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ansible on the other hand uses the API Key formatted as&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;user: &quot;terraform@pve&quot;
token_id: &quot;provider&quot;
token_secret: &quot;REDACTED&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The information is the same, but formatted differently. Fortunately for us, ansible supports &lt;a href=&quot;https://docs.ansible.com/ansible/latest/playbook_guide/playbooks_filters.html#searching-strings-with-regular-expressions&quot;&gt;searching strings with regular expressions&lt;/a&gt;. The regex to break it down into the three parts we need is rather simple:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;([^!]+)!([^=]+)=(.+)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ansible also has a &lt;a href=&quot;https://docs.ansible.com/ansible/latest/collections/ansible/builtin/env_lookup.html&quot;&gt;lookup method&lt;/a&gt; to read environment variables. Let&apos;s put all the pieces together in our dynamic inventory file&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;plugin: community.general.proxmox
url: https://192.168.1.200:8006
user: &quot;terraform@pve&quot;
token_id: &quot;provider&quot;
token_secret: &quot;REDACTED&quot;
user: &quot;{{ (lookup(&apos;ansible.builtin.env&apos;, &apos;PROXMOX_VE_API_TOKEN&apos;) | regex_search(&apos;([^!]+)!([^=]+)=(.+)&apos;, &apos;\\1&apos;))[0] }}&quot;
token_id: &quot;{{ (lookup(&apos;ansible.builtin.env&apos;, &apos;PROXMOX_VE_API_TOKEN&apos;) | regex_search(&apos;([^!]+)!([^=]+)=(.+)&apos;, &apos;\\2&apos;))[0] }}&quot;
token_secret: &quot;{{ (lookup(&apos;ansible.builtin.env&apos;, &apos;PROXMOX_VE_API_TOKEN&apos;) | regex_search(&apos;([^!]+)!([^=]+)=(.+)&apos;, &apos;\\3&apos;))[0] }}&quot;
want_facts: true
groups:
  k3s: &quot;&apos;k3s&apos; in (proxmox_tags_parsed|list)&quot;
compose:
  ansible_host: proxmox_ipconfig0.ip | default(proxmox_net0.ip) | ansible.utils.ipaddr(&apos;address&apos;)
validate_certs: false
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Voilà! Just like that we have removed the secrets from our files, and we&apos;re ready to commit them!&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!info] Why not use the &lt;a href=&quot;https://docs.ansible.com/ansible/latest/collections/community/general/bitwarden_lookup.html&quot;&gt;Bitwarden plugin for ansible&lt;/a&gt;?&lt;/p&gt;
&lt;p&gt;It&apos;s a good alternative, but I already rely on direnv to extract the relevant secrets from my vault and store them temporarily in environment variables.&lt;/p&gt;
&lt;p&gt;Using the Bitwarden plugin in my playbook would tightly couple the playbook to ansible. By relying on the environment variables, only direnv is coupled to Bitwarden!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now we can spin up a VM and install k3s on it in a handful of seconds! Our homelab is making steady progress. Next up we will see how to get services running on our cluster with GitOps!&lt;/p&gt;
&lt;p&gt;&lt;em&gt;A huge thanks to my friends and colleagues Davide, &lt;a href=&quot;https://sandhose.fr&quot;&gt;Quentin&lt;/a&gt;, Ark, &lt;a href=&quot;https://half-shot.uk&quot;&gt;Half-Shot&lt;/a&gt;, and Ben!&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>TIL that You can spot base64 encoded JSON, certificates, and private keys</title><link>https://ergaster.org/til/base64-encoded-json/</link><guid isPermaLink="true">https://ergaster.org/til/base64-encoded-json/</guid><description>A colleague spotted base64-encoded JSON in a long string of gibberish without any tools. I was amazed, until he explained how anyone can do it.</description><pubDate>Tue, 05 Aug 2025 13:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I was working on my homelab and examined a file that was supposed to contain encrypted content that I could safely commit on a Github repository. The file looked like this&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{
  &quot;serial&quot;: 13,
  &quot;lineage&quot;: &quot;24d431ee-3da9-4407-b649-b0d2c0ca2d67&quot;,
  &quot;meta&quot;: {
    &quot;key_provider.pbkdf2.password_key&quot;: &quot;eyJzYWx0IjoianpHUlpMVkFOZUZKcEpSeGo4UlhnNDhGZk9vQisrR0YvSG9ubTZzSUY5WT0iLCJpdGVyYXRpb25zIjo2MDAwMDAsImhhc2hfZnVuY3Rpb24iOiJzaGE1MTIiLCJrZXlfbGVuZ3RoIjozMn0=&quot;
  },
  &quot;encrypted_data&quot;: &quot;ONXZsJhz37eJA[...]&quot;,
  &quot;encryption_version&quot;: &quot;v0&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Hm, key provider? Password key? In an encrypted file? That doesn&apos;t sound right. The problem is that this file is generated by taking a password, deriving a key from it, and encrypting the content with that key. I don&apos;t know what the derived key could look like, but it could be that long indecipherable string.&lt;/p&gt;
&lt;p&gt;I asked a colleague to have a look and he said &quot;Oh that? It looks like a base64 encoded JSON. Give it a go to see what&apos;s inside.&quot;&lt;/p&gt;
&lt;p&gt;I was incredulous but gave it a go, and it worked!!&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ echo &quot;eyJzYW[...]&quot; | base64 -d
{&quot;salt&quot;:&quot;jzGRZLVANeFJpJRxj8RXg48FfOoB++GF/Honm6sIF9Y=&quot;,&quot;iterations&quot;:600000,&quot;hash_function&quot;:&quot;sha512&quot;,&quot;key_length&quot;:32}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I couldn&apos;t believe my colleague had decoded the base64 string on the fly, so I asked. &quot;What gave it away? Was it the trailing equal signs at the end for padding? But how did you know it was base64 encoded &lt;em&gt;JSON&lt;/em&gt; and not just a base64 string?&quot;&lt;/p&gt;
&lt;p&gt;He replied,&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Whenever you see &lt;code&gt;ey&lt;/code&gt;, that&apos;s &lt;code&gt;{&quot;&lt;/code&gt; and then if it&apos;s followed by a letter, you&apos;ll get &lt;code&gt;J&lt;/code&gt; followed by a letter.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I did a few tests in my terminal, and he was right! You can spot base64 json with your naked eye, and you don&apos;t need to decode it on the fly!&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ echo &quot;{&quot; | base64
ewo=
$ echo &quot;{\&quot;&quot; | base64
eyIK
$ echo &quot;{\&quot;s&quot; | base64
eyJzCg==
$ echo &quot;{\&quot;a&quot; | base64
eyJhCg==
$ echo &quot;{\&quot;word\&quot;&quot; | base64
eyJ3b3JkIgo=
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But there&apos;s even better! As &lt;a href=&quot;https://toot.now/@tyzbit&quot;&gt;tyzbit&lt;/a&gt; reported on the fediverse, you can even spot base64 encoded certificates and private keys! They all start with &lt;code&gt;LS&lt;/code&gt;, which reminds of the LS in &quot;TLS certificate.&quot;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ echo -en &quot;-----BEGIN CERTIFICATE-----&quot; | base64
LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0t
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;[!warning] Errata&lt;/p&gt;
&lt;p&gt;As pointed out by &lt;a href=&quot;https://news.ycombinator.com/item?id=44803260&quot;&gt;gnabgib&lt;/a&gt; and &lt;a href=&quot;https://news.ycombinator.com/item?id=44803259&quot;&gt;athorax&lt;/a&gt; on Hacker News, this actually detects the leading dashes of the PEM format, commonly used for certificates, and a YAML file that starts with &lt;code&gt;---&lt;/code&gt; will yield the same result&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ echo &quot;---\n&quot; | base64
LS0tXG4K
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is not a silver bullet!&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;Thanks Davide and Denis for showing me this simple but pretty useful trick, and thanks tyzbit for completing it with certs and private keys!&lt;/em&gt;&lt;/p&gt;
</content:encoded></item><item><title>Over engineering my homelab so I don&apos;t pay cloud providers</title><link>https://ergaster.org/posts/2025/08/04-overegineering-homelab/</link><guid isPermaLink="true">https://ergaster.org/posts/2025/08/04-overegineering-homelab/</guid><description>After years on a VPS, I&apos;m moving my self-hosted services home and using the opportunity to migrate to a more flexible and robust setup.</description><pubDate>Mon, 04 Aug 2025 13:00:00 GMT</pubDate><content:encoded>&lt;p&gt;After years of self-hosting on a VPS in a datacenter, I&apos;ve decided to move my services at home. But instead of just porting services, I&apos;m using this as an opportunity to migrate to a more flexible and robust set up.&lt;/p&gt;
&lt;p&gt;I will deploy services on a single mini pc. Since I need to be able to experiment and learn without disrupting my services, I will need to be able to spin up Virtual Machines (VMs). Let&apos;s explore how I deployed &lt;a href=&quot;https://www.proxmox.com/en/products/proxmox-virtual-environment/overview&quot;&gt;Proxmox Virtual Environment&lt;/a&gt; on a safe host for my specific needs as a homelabber, and how I automated as much of it as possible. In a follow-up post we will explore how to spin-up and configure VMs in a reproducible way on that setup.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!warning] This guide has been updated&lt;/p&gt;
&lt;p&gt;I initially wrote this guide for Debian Bookworm, but have now updated it for Debian Trixie. The major change lies in how to install the Proxmox repository and GPG key, that have change in Debian Trixie.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;What I want to do and avoid&lt;/h2&gt;
&lt;h3&gt;Objectives&lt;/h3&gt;
&lt;p&gt;After realizing that my good old &lt;a href=&quot;/posts/2025/06/24-why-restore-raspi-slow/&quot;&gt;Raspberry Pi 4 was too slow to let me backup or restore&lt;/a&gt; on an encrypted disk, I bought a &lt;a href=&quot;https://www.minisforum.com/products/minisforum-um880-plus&quot;&gt;Minisforum UM880 Plus&lt;/a&gt;. At €600 it was not extremely expensive, but I don&apos;t intend to spend more on hardware in the foreseeable future and I want to make the most of what I have right now.&lt;/p&gt;
&lt;p&gt;I love to experiment and would like to do it safely without putting my production set-up at risk. Those are self-hosted services mostly for my personal usage, so I can afford occasional downtime, but I don&apos;t want to have to rebuild everything if my experiments go wrong. I also don&apos;t want to experiment by spinning up VMs at a cloud provider, because I will &lt;em&gt;not&lt;/em&gt; know what I&apos;m doing while learning, and cloud providers can get expensive very quickly.&lt;/p&gt;
&lt;p&gt;One of my main objectives as I write these lines is to get up to speed with Kubernetes. I want to stay on a single-node k3s deployment while I get comfortable with operating services on a Kubernetes cluster, but I know I will want to explore deployments with several nodes, and eventually create a full blown k8s cluster based on &lt;a href=&quot;https://www.talos.dev/&quot;&gt;Talos Linux&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Threat model&lt;/h3&gt;
&lt;p&gt;My server is in my living room. The most prominent threat in my model is a burglary. If my server gets stolen I will lose access to my infrastructure and my data. I also don&apos;t want my data to leak in the wild if the burglars put their hands on the disk in my server.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!info] I need to have disk encryption and solid backups to keep my data safe&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The second biggest threat is hardware failure. All devices can fail, but I&apos;m fairly certain this is particularly true of a €600 mini pc that was not necessarily designed to serve as a home server.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!info] I need to have a setup that can be automatically installed and configured&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I am also a team of only one, I am fallible, and I don&apos;t have peers to review my exact set-up. To mitigate this risk I have a group of friends called the &lt;em&gt;Infra Nerds Club&lt;/em&gt;, whom I regularly ask for advice.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!info] I need to have a versioned set-up that can easily be rolled back&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;My ISP-provided router supports wireguard. Even when I&apos;m out, I can join the local network of my server. But my server could be shut down because of a power outage or another reason. I might be at work or even on holidays when it happens, and even wireguard can&apos;t solve this.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!info] I need a KVM on my local network so I can send Wake on LAN packages to my server&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Unsurprisingly with my objectives and hardware constraints, I need to be able to spin up VMs to play with. The only realistic option on the table for a hobby homelabber is &lt;a href=&quot;https://www.proxmox.com/en/products/proxmox-virtual-environment/overview&quot;&gt;Proxmox Virtual Environment&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It is important to also highlight that if my server gets stolen or fails, I will not be able to spin up a hypervisor on a baremetal server again, and VPS providers would likely not let me configure a bridged network like I will do below.&lt;/p&gt;
&lt;p&gt;The hypervisor and virtual machines I will deploy are just meant to give me flexibility. I consider the hypervisor and Virtual Machines as disposable, so I will not perform backups of the VMs themselves. I will however perform backups of the data and configuration of the services running on it.&lt;/p&gt;
&lt;p&gt;One of my goals is to be able to quickly move my infrastructure to a cloud provider if something happened to my baremetal server, and back to a new baremetal server after it&apos;s been delivered.&lt;/p&gt;
&lt;h3&gt;Implementing it&lt;/h3&gt;
&lt;p&gt;I will deploy a Proxmox hypervisor on the physical server in my living room. On that hypervisor, I want to be able to statically declare what VMs must be spun up, how they should be configured, how the services (e.g. k3s) are deployed on those VMs, and what DNS records must be set to reach those services. There isn&apos;t a single unified tool to do so, and I will have to rely on &lt;a href=&quot;https://opentofu.org/&quot;&gt;opentofu&lt;/a&gt;, &lt;a href=&quot;https://cloud-init.io/&quot;&gt;cloud init&lt;/a&gt; and &lt;a href=&quot;https://docs.ansible.com/&quot;&gt;ansible&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;In this post I will only focus on deploying a rock stable Proxmox hypervisor on my server, but it&apos;s worth having a glimpse at how I will manage it.&lt;/p&gt;
&lt;p&gt;Opentofu, and the mother project &lt;a href=&quot;https://developer.hashicorp.com/terraform&quot;&gt;Terraform&lt;/a&gt; it originated from, are often described as Infra as Code (IaC). In other words, it lets you describe in a text file what VMs you want to create on your infrastructure. With cloud-init, you can add a basic configuration for your VM, such as the users credentials, ssh keys to trust, and network configuration.&lt;/p&gt;
&lt;p&gt;A typical opentofu snippet to spin up a VM with a Debian OS pre-configured with cloud-init looks like this. We will explain how to actually use opentofu and cloud-init to spin up VMs in a further blog post.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;resource &quot;proxmox_virtual_environment_vm&quot; &quot;k3s-main&quot; {
  name        = &quot;k3s-main&quot;
  description = &quot;Production k3s&apos; main VM&quot;
  tags        = [&quot;production&quot;, &quot;k3s&quot;, &quot;debian&quot;]
  node_name   = &quot;proximighty&quot;

  cpu {
    cores = 4
    type  = &quot;x86-64-v4&quot;
  }

  memory {
    dedicated = 4096
    floating  = 4096
  }

  disk {
    datastore_id = &quot;local&quot;
    interface    = &quot;virtio0&quot;
    iothread     = true
    size         = 50
    file_id      = proxmox_virtual_environment_download_file.debian_cloud_image.id
  }

  [...]
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Opentofu runs from my laptop. It reads the &lt;code&gt;.tf&lt;/code&gt; files and will talk to the Proxmox host to spin up VMs and their basic configuration. It can also talk to my registrar (Cloudflare for now) to add new DNS records if I ask it to.&lt;/p&gt;
&lt;p&gt;Having a VM pre-configured with network, users and trusted ssh keys is very useful to hook in the second configuration tool: ansible.&lt;/p&gt;
&lt;p&gt;An ansible playbook is a text file describing the desired state of a server, often without describing how it must be achieved. For example, instead of describing &quot;Open the file &lt;code&gt;/etc/hosts&lt;/code&gt; and add the line &lt;code&gt;192.168.1.200 myhost.example.com&lt;/code&gt;&quot;, you describe &quot;the line &lt;code&gt;192.168.1.200 myhost.example.com&lt;/code&gt; must be present in the file &lt;code&gt;/etc/hosts&lt;/code&gt;&quot;.&lt;/p&gt;
&lt;p&gt;It can sound like the same, but it&apos;s not: running the first description twice would result in the same line being added twice to the &lt;code&gt;/etc/hosts&lt;/code&gt; file. Running the second description would result in having the desired lined only once. A typical playbook will look like this.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
- name: Set the timezone to UTC
  community.general.timezone:
    name: UTC

- name: Install bridge utils
  ansible.builtin.apt:
    name: bridge-utils
    state: present

- name: Override Debian&apos;s default network configuration
  ansible.builtin.copy:
    src: interfaces
    dest: /etc/network/interfaces
    mode: &quot;0644&quot;

- name: Create a bridge interface vrm0 and give it a static IP
  ansible.builtin.copy:
    src: vmbr0
    dest: /etc/network/interfaces.d/vrm0
    mode: &quot;0644&quot;

- name: Ensure enp2s0 doesn&apos;t have an IP
  ansible.builtin.copy:
    src: enp2s0
    dest: /etc/network/interfaces.d/enp2s0
    mode: &quot;0644&quot;

- name: Add local IP to the hosts filename
  ansible.builtin.lineinfile:
    path: /etc/hosts
    line: 192.168.1.200 proximighty.ergaster.org proximighty
    create: true
    mode: &quot;0644&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ansible also runs from my laptop. It reads the playbook&apos;s &lt;code&gt;.yaml&lt;/code&gt; files, and uses ssh to log into the target machine and apply the playbook configuration.&lt;/p&gt;
&lt;h2&gt;Setting up the Proxmox host&lt;/h2&gt;
&lt;h3&gt;Installing an encrypted Debian&lt;/h3&gt;
&lt;p&gt;Proxmox is based on Debian and can be installed in 3 different way:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Via the &lt;a href=&quot;https://pve.proxmox.com/pve-docs/chapter-pve-installation.html#installation_installer&quot;&gt;official installer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;By creating an &lt;a href=&quot;https://pve.proxmox.com/wiki/Automated_Installation&quot;&gt;automated installer&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;By installing it &lt;a href=&quot;https://pve.proxmox.com/wiki/Install_Proxmox_VE_on_Debian_13_Trixie&quot;&gt;on top of an existing Debian install&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The second option sounds very appealing, but there is a major issue: &lt;strong&gt;the Proxmox (automated) installer doesn&apos;t support setting up disk encryption&lt;/strong&gt;. The simplest way to have disk encryption on the host is to install Debian first, and to install Proxmox on top.&lt;/p&gt;
&lt;p&gt;I could automate the Debian install using &lt;a href=&quot;https://www.debian.org/releases/stable/amd64/apb.en.html&quot;&gt;preseeding&lt;/a&gt;. Preseed files contain the answers to the questions asked by the Debian installer. A colleague who wrote a preseed file for Debian 8 told me he didn&apos;t have to update it since. After writing my own preseed, I could [add it to the Debian netinst usb disk](&lt;a href=&quot;https://wiki.debian.org/DebianInstaller/WritableUSBStick&quot;&gt;preseed on usb disk&lt;/a&gt;) and Debian would be installed automatically without human intervention.&lt;/p&gt;
&lt;p&gt;But preseed files can only customize the basic install of Debian. To install additional packages (like Proxmox) and configure my machine I need to rely on an ansible playbook.&lt;/p&gt;
&lt;p&gt;It is also worth noting that if my server got stolen or if its hardware failed, I wouldn&apos;t be able to replace it with a baremetal server right away. I would have to choose a cloud provider and spin up VMs that roughly correspond to the ones I had running on my Proxmox.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!info] Using a preseed would be a case of &lt;a href=&quot;https://xkcd.com/1205/&quot;&gt;XKCD 1205&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I shouldn&apos;t have to perform regular reinstalls of Debian for the Proxmox host, and I need to write an ansible playbook to configure it properly anyway. I would spend a lot of time automating the Debian install, but I wouldn&apos;t save a lot of time in doing so.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I grabbed a Debian netinstall and performed a regular install with disk encryption, with the following specificities:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;I used the full disk with LVM, and set up disk encryption.&lt;/li&gt;
&lt;li&gt;I didn&apos;t let the installer fill my disk with random data because it takes a lot of time and doesn&apos;t match my threat model.&lt;/li&gt;
&lt;li&gt;I &lt;strong&gt;did&lt;/strong&gt; set a root password. Proxmox is very root centric, and while there are workarounds to use a non-root user, it gets tedious very fast for little extra security.&lt;/li&gt;
&lt;li&gt;At the package selection step, I disabled everything but &lt;code&gt;SSH Server&lt;/code&gt; and &lt;code&gt;standard system utilities&lt;/code&gt;. I need both to be able to ssh into my server and let ansible control my Proxmox host.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;[!warning] Keyboard required&lt;/p&gt;
&lt;p&gt;The disk is encrypted by a password. The server will prompt me for the password when it (re)starts and will not be able to boot if I don&apos;t type the password.&lt;/p&gt;
&lt;p&gt;My server is connected to a &lt;a href=&quot;https://jetkvm.com/&quot;&gt;KVM&lt;/a&gt;, so I can enter the disk encryption password when the server reboots. If you don&apos;t, you can install and configure &lt;a href=&quot;https://matt.ucc.asn.au/dropbear/dropbear.html&quot;&gt;Dropbear&lt;/a&gt; to do it over ssh or create a magic usb stick that LUKS will read to decrypt the disk.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Now, I need to interact with my . After installing Debian and unlocking the disk, I need to configure the ssh server to let me temporarily log in as root to copy my public key. Via my KVM, I update the &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; as follows&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PermitRootLogin prohibit-password
PermitRootLogin yes
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And I restart the sshd so I can log in as root&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On my laptop, I copy my public key to the server with&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ssh-copy-id root@192.168.1.200
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And finally I reverse the change on my server by editing &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; again so I can only log in by ssh key&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;PermitRootLogin yes
PermitRootLogin prohibit-password
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One restart of the sshd later, my server is safe again&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# systemctl restart sshd
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Installing and configuring Proxmox&lt;/h3&gt;
&lt;h4&gt;Installing Proxmox&lt;/h4&gt;
&lt;p&gt;Installing Proxmox on top of an existing Debian is a &lt;a href=&quot;https://pve.proxmox.com/wiki/Install_Proxmox_VE_on_Debian_13_Trixie&quot;&gt;well supported and documented process&lt;/a&gt;. I followed these steps on my encrypted Debian until the Proxmox VE package install and... my machine didn&apos;t boot anymore. It was very confusing at first sight, because &lt;em&gt;there was no error&lt;/em&gt;. I was prompted for my disk encryption password, the disk was successfully unlocked, and then &lt;em&gt;nothing&lt;/em&gt;. The system just didn&apos;t boot, was unreachable via SSH, and didn&apos;t display anything via the KVM.&lt;/p&gt;
&lt;h4&gt;Figuring out why installing Proxmox bricks my Debian&lt;/h4&gt;
&lt;p&gt;I was extremely surprised that installing a vanilla Proxmox on a freshly installed, pristine Debian would completely brick the system!&lt;/p&gt;
&lt;p&gt;I initially thought that the issue was the Proxmox kernel that didn&apos;t support disk encryption, or that didn&apos;t support my hardware well. After a few reinstalls and rebooting between the install steps, I figured out that booting on the Proxmox kernel &lt;em&gt;without&lt;/em&gt; Promox VE installed worked perfectly fine. Even from an encrypted disk.&lt;/p&gt;
&lt;p&gt;So I installed Proxmox, and asked the computer to tell me what it does when it boots. To do so, I wait for the GRUB screen to appear, and pressed &amp;lt;kbd&amp;gt;e&amp;lt;/kbd&amp;gt; to get access to the boot command editor.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;I replaced the &lt;code&gt;quiet&lt;/code&gt; boot parameter by &lt;code&gt;noquiet&lt;/code&gt;, and pressed &amp;lt;kbd&amp;gt;Ctrl&amp;lt;/kbd&amp;gt; + &amp;lt;kbd&amp;gt;x&amp;lt;/kbd&amp;gt; to save my changes and boot with this altered command. I could see that the machine was stuck on &lt;code&gt;Job networking.service/start running&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Looking up &lt;code&gt;Proxmox job networking start running&lt;/code&gt; yielded good results on the Proxmox forums. In &lt;a href=&quot;https://forum.proxmox.com/threads/solved-pve-host-stuck-on-job-networking-service-start-running.134405/#post-593629&quot;&gt;this thread&lt;/a&gt; and &lt;a href=&quot;https://forum.proxmox.com/threads/proxmox-wont-start-with-networking.134410/#post-605261&quot;&gt;that one&lt;/a&gt; users say ntp is causing issues. But I didn&apos;t have ntp, ntpsec-ntpdate or any related package installed!&lt;/p&gt;
&lt;p&gt;After a few reinstalls and a bit of trial and error, I could figure out that my machine wouldn&apos;t boot after installing Proxmox VE if I didn&apos;t set up a static IP configuration for it. Configuring a static IP for the machine after a fresh reinstall fixed the issue.&lt;/p&gt;
&lt;h4&gt;Setting up a bridge network&lt;/h4&gt;
&lt;p&gt;I only have a single physical &lt;code&gt;enp2s0&lt;/code&gt; network interface card on my host, but I will have several guest VMs. Each VM needs to be able to use my host&apos;s network card and make it &quot;impersonate&quot; its virtual card. I&apos;m writing a more detailed post about how this works, but the gist of it is that you need to create a virtual network interface &lt;code&gt;vrmb0&lt;/code&gt; called a bridge. The bridge will be connected both to the host&apos;s physical network card, and to the VMs network interface.&lt;/p&gt;
&lt;p&gt;&lt;/p&gt;
&lt;p&gt;The physical network card no longer operates at the IP level: it merely serves as a packet sender and receiver. So I need to remove the default IP configuration on &lt;code&gt;enp2s0&lt;/code&gt;, and configure &lt;code&gt;vrmb0&lt;/code&gt; to have an IP the host will be able to use instead.&lt;/p&gt;
&lt;p&gt;Since I installed a minimal Debian, I need to install the required tools to create bridged networks&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# apt install bridge-utils
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then, let&apos;s clean up the default network configuration in &lt;code&gt;/etc/network/interfaces&lt;/code&gt; to only keep the loopback interface&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).

source /etc/network/interfaces.d/*

# The loopback network interface
auto lo
iface lo inet loopback
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Let&apos;s add a file in &lt;code&gt;/etc/network/interfaces.d/&lt;/code&gt; for &lt;code&gt;enp2s0&lt;/code&gt; to be brought up but not try to get an IP&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;auto enp2s0
iface enp2s0 inet manual
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And now let&apos;s create and configure &lt;code&gt;vrmb0&lt;/code&gt; to have a static IP&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;auto vmbr0
iface vmbr0 inet static
        address 192.168.1.200
        gateway 192.168.1.254
        bridge_ports enp2s0
        bridge_stp off
        bridge_fd 0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I can finally restart the network to ensure everything is configured properly&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# systemctl restart networking
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That was a lot of work, and I&apos;m not sure I will remember how to perform all of these steps if I need to rebuild a Proxmox host. Let&apos;s use ansible to automate everything I did after installing a clean Debian!&lt;/p&gt;
&lt;h2&gt;Automating the Proxmox install with ansible&lt;/h2&gt;
&lt;h3&gt;Installing ansible&lt;/h3&gt;
&lt;p&gt;The ansible documentation lists &lt;a href=&quot;https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html&quot;&gt;several ways to install ansible&lt;/a&gt;. I didn&apos;t find anything related to homebrew, but the package is still present and seems up to date. Since I regularly upgrade the packages installed with homebrew, I decided it was the simplest way to keep an up to date ansible on my laptop and installed with&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ brew install ansible
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Writing the playbook&lt;/h3&gt;
&lt;p&gt;I created a &lt;code&gt;~/Projects/infra&lt;/code&gt; folder that will contain everything related to my homelab. In this directory, I created two subdirectories: one called &lt;code&gt;opentofu&lt;/code&gt; that we will use later to spin up VMs, and one called &lt;code&gt;ansible&lt;/code&gt; that will contain my playbooks.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra
$ tree -L 1
.
├── ansible
└── tofu
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I want to keep the ansible playbook for my infrastructure in a single place. At the root of my ansible repository, I have created two folders: &lt;code&gt;inventory&lt;/code&gt; and &lt;code&gt;proximighty&lt;/code&gt; (the name of the Proxmox host).&lt;/p&gt;
&lt;p&gt;In the inventory folder I can list all my hosts and organize how I want. I created a &lt;code&gt;production&lt;/code&gt; file that contains the following&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[proximighty]
192.168.1.200 ansible_ssh_user=root
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Since I don&apos;t have a local DNS set-up and I&apos;m not too keen on using my public domain name for my internal network, I&apos;ll stick to the host IP. I&apos;ve put it under the &lt;code&gt;[proximighty]&lt;/code&gt; group so I can easily refer to it later in ansible, and specified that ansible must ssh as root into the machine to perform operations.&lt;/p&gt;
&lt;p&gt;I then create a &lt;code&gt;proximighty&lt;/code&gt; folder under &lt;code&gt;ansible&lt;/code&gt; where I will describe everything that must be done on a fresh Debian to get it to the desired state. I create a &lt;code&gt;configure.yaml&lt;/code&gt; that will be the root of my playbook.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/ansible
$ tree -L 2
.
├── inventory
│   └── production
└── proximighty
    └── configure.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In the &lt;code&gt;configure.yaml&lt;/code&gt; file I describe the rough steps. In my case, I want to do two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Configure the host. That means setting the timezone to UTC, and installing the &lt;code&gt;kitty-terminfo&lt;/code&gt; package so I can use &lt;a href=&quot;https://sw.kovidgoyal.net/kitty/&quot;&gt;kitty&lt;/a&gt; with my server.&lt;/li&gt;
&lt;li&gt;Install Proxmox.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The basic structure looks like this&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
- name: Configure the host
  hosts: proximighty
  tasks:
    - name: Set timezone to UTC
      community.general.timezone:
        name: UTC

    - name: Install kitty files
      ansible.builtin.apt:
        name: kitty-terminfo
        state: present

- name: Install Proxmox
  hosts: proximighty
  tasks:
    - name: Install Proxmox
      ???
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I left question marks at the end of the file, because there are quite a few steps to install Proxmox, including reboots. To keep the playbook readable, I will isolate these steps into their own module. Ansible calls this module a role. Let&apos;s go to the &lt;code&gt;proximighty&lt;/code&gt; folder and create a &lt;code&gt;role&lt;/code&gt; folder in it. Inside it we can create a &lt;code&gt;proxmox&lt;/code&gt; folder that will contain all the instructions to install proxmox.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/ansible
$ tree -L 3
.
├── inventory
│   └── production
└── proximighty
    ├── configure.yaml
    └── roles
        └── proxmox
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The entry point of a task is a &lt;code&gt;main.yaml&lt;/code&gt; file nested inside a &lt;code&gt;tasks&lt;/code&gt; folder, so let&apos;s create the relevant file structure&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/ansible/proximighty
$ tree -L 4
.
├── configure.yaml
└── roles
    └── proxmox
        └── tasks
            └── main.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally we can open open the &lt;code&gt;main.yaml&lt;/code&gt; file and start describing the steps necessary to install Proxmox! The file starts with &lt;code&gt;---&lt;/code&gt; and will then contain the various steps. Let&apos;s start by ensuring that the &lt;code&gt;bridge-utils&lt;/code&gt; package is present, so we can set-up a bridged network&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
- name: Install bridge utils
  ansible.builtin.apt:
    name: bridge-utils
    state: present
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then we will fiddle with the network files. We want to&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Override the default configuration in &lt;code&gt;/etc/network/interfaces&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Create a bridge interface &lt;code&gt;vmbr0&lt;/code&gt; described by a file in &lt;code&gt;/etc/network/interfaces.d/vmbr0&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Configure &lt;code&gt;enp2s0&lt;/code&gt; with a file in &lt;code&gt;/etc/network/interfaces.d/enp2s0&lt;/code&gt; so it doesn&apos;t try to get its own IP&lt;/li&gt;
&lt;li&gt;Add a local IP into &lt;code&gt;/etc/hosts&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Restart the network&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Let&apos;s describe that in ansible terms&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[...]

- name: Remove Debian&apos;s default network configuration
  ansible.builtin.copy:
    src: interfaces
    dest: /etc/network/interfaces
    mode: &quot;0644&quot;

- name: Create a bridge interface vrm0 and give it a static IP
  ansible.builtin.copy:
    src: vmbr0
    dest: /etc/network/interfaces.d/vrm0
    mode: &quot;0644&quot;

- name: Ensure enp2s0 doesn&apos;t have an IP
  ansible.builtin.copy:
    src: enp2s0
    dest: /etc/network/interfaces.d/enp2s0
    mode: &quot;0644&quot;

- name: Add local IP to the hosts filename
  ansible.builtin.lineinfile:
    path: /etc/hosts
    line: 192.168.1.200 proximighty.ergaster.org  proximighty
    create: true
    mode: &quot;0644&quot;

- name: Restart the networking service
  ansible.builtin.systemd_service:
    name: networking
    state: restarted
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We&apos;re asking ansible to copy files over to the server, but we didn&apos;t tell it where to take the source files. By default, ansible looks up file in a &lt;code&gt;files&lt;/code&gt; folder at the root of the role. Let&apos;s create the relevant files then:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/ansible/proximighty
$ tree -L 4
.
├── configure.yaml
└── roles
    └── proxmox
        ├── files
        │   ├── enp2s0
        │   ├── interfaces
        │   └── vmbr0
        └── tasks
            └── main.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The content of the files is the same as in the previous section. Now, to install Proxmox we need to add the Proxmox apt repositories to our apt list. For apt to trust it, we need to add the Proxmox signing key. We need the &lt;code&gt;gpg&lt;/code&gt; package to be able to manipulate it. So let&apos;s add those steps in our &lt;code&gt;proxmox/tasks/main.yaml&lt;/code&gt; file&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[...]

- name: Ensure gpg is installed
  ansible.builtin.apt:
    name: gpg
    state: present

- name: Add the Proxmox key
  ansible.builtin.get_url:
    url: https://enterprise.proxmox.com/debian/proxmox-archive-keyring-trixie.gpg
    dest: /usr/share/keyrings/proxmox-archive-keyring.gpg
    checksum: sha256:136673be77aba35dcce385b28737689ad64fd785a797e57897589aed08db6e45
    mode: &quot;0644&quot;

- name: Add pve-no-subscription repository
  ansible.builtin.copy:
    src: pve-install-repo.sources
    dest: /etc/apt/sources.list.d/pve-install-repo.sources
    mode: &quot;0644&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&apos;m also adding the repository file to the list of the role files in &lt;code&gt;roles/proxmox/files/pve-install-repo.sources&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: trixie
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
Architechtures: amd64
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally we can update all the packages, ensure no package related to ntp is present, and reboot. Let&apos;s add those instructions to &lt;code&gt;proxmox/tasks/main.yaml&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[...]

- name: Update all packages
  ansible.builtin.apt:
    upgrade: full
    update_cache: true
  notify: Reboot

- name: Ensure ntp and related packages are absent
  ansible.builtin.apt:
    name:
      - ntp
      - ntpsec
      - ntpsec-ntpdate
    state: absent
  notify: Reboot

- name: Reboot after upgrading packages
  ansible.builtin.meta: flush_handlers
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You probably noticed the &lt;code&gt;notify: Reboot&lt;/code&gt; that appears twice. It could look like the machine is going to reboot twice, but this is not the case. It means each step will notify the &lt;code&gt;Reboot&lt;/code&gt; handler, but handlers are only called at the end of a task... unless they are flushed before the end of the task. We explicitly flush the handlers with &lt;code&gt;ansible.builtin.meta: flush_handlers&lt;/code&gt;, so the reboot will only happen here.&lt;/p&gt;
&lt;p&gt;We called a handler, but we didn&apos;t define it anywhere. Like for files, ansible has a default place to look up for handlers: the &lt;code&gt;handlers&lt;/code&gt; directory at the root of the module. Let&apos;s create the relevant files.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/ansible/proximighty
$ tree -L 4
.
├── configure.yaml
└── roles
    └── proxmox
        ├── files
        │   ├── enp2s0
        │   ├── interfaces
        │   ├── storage.cfg
        │   └── vmbr0
        ├── handlers
        │   └── main.yaml
        └── tasks
            └── main.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And let&apos;s add the &lt;code&gt;Reboot&lt;/code&gt; handler in there&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
- name: Reboot
  ansible.builtin.reboot:
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;We can now finalize the install by&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Installing the Proxmox kernel and rebooting&lt;/li&gt;
&lt;li&gt;Installing Proxmox VE and dependancies&lt;/li&gt;
&lt;li&gt;Removing the Debian kernel and os-prober&lt;/li&gt;
&lt;li&gt;Removing the pve-enterprise repository that Proxmox automatically installed&lt;/li&gt;
&lt;li&gt;Rebooting one last time&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Let&apos;s append those steps to the &lt;code&gt;proxmox/tasks/main.yaml&lt;/code&gt; file&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[...]

- name: Install Proxmox VE Kernel
  ansible.builtin.apt:
    name: &quot;proxmox-default-kernel&quot;
    state: present
  notify: Reboot

- name: Reboot after installing Proxmox VE Kernel
  ansible.builtin.meta: flush_handlers

- name: Install Proxmox VE and dependencies
  ansible.builtin.apt:
    name:
      - proxmox-ve
      - postfix
      - open-iscsi
      - chrony
    state: present

- name: Remove the Debian kernel and os-prober
  ansible.builtin.apt:
    name:
      - linux-image-amd64
      - os-prober
    state: absent
  notify:
    - Update GRUB
    - Reboot

- name: Remove pve-enterprise repository
  ansible.builtin.apt_repository:
    repo: deb https://enterprise.proxmox.com/debian/pve {{ debian_version }} pve-enterprise
    state: absent
    update_cache: true
    filename: pve-enterprise

- name: Reboot after installing Proxmox VE and removing old kernels
  ansible.builtin.meta: flush_handlers
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You might notice the extra &lt;code&gt;Update GRUB&lt;/code&gt; handler, that we need to also add to our &lt;code&gt;handlers&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[...]

- name: Update GRUB
  ansible.builtin.command: update-grub
  changed_when: true
  notify: Reboot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Finally, we can wrap it all together by calling this proxmox role from our main &lt;code&gt;configure.yaml&lt;/code&gt; file&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;---
- name: Configure the host
  hosts: proximighty
  tasks:
    - name: Set timezone to UTC
      community.general.timezone:
        name: UTC

    - name: Install kitty files
      ansible.builtin.apt:
        name: kitty-terminfo
        state: present

- name: Install Proxmox
  hosts: proximighty
  tasks:
    - name: Install Proxmox
      ansible.builtin.import_role:
        name: proxmox
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It&apos;s now time to execute that playbook!&lt;/p&gt;
&lt;h3&gt;Executing the playbook&lt;/h3&gt;
&lt;p&gt;After writing this playbook, it&apos;s now time to execute it! To be able to execute this playbook, we need to be able to ssh as root on the Debian host that will get Proxmox installed, with a ssh key and not a password.&lt;/p&gt;
&lt;p&gt;As a quick test, running &lt;code&gt;ssh root@192.168.1.200&lt;/code&gt; should log me in without prompting me for a password or a fingerprint verification.&lt;/p&gt;
&lt;p&gt;From my laptop, I go to the &lt;code&gt;ansible&lt;/code&gt; directory, from which I can run a command to invoke the &lt;code&gt;configure.yaml&lt;/code&gt; playbook with the &lt;code&gt;production&lt;/code&gt; inventory like so&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ cd ~/Projects/infra/ansible
$ ansible-playbook -i inventory/production proximighty/configure.yaml
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Ansible will install everything and occasionally reboot the server when needed. Since my server has an encrypted disk, I need to monitor what&apos;s happening on my KVM and unlock the disk with my encryption passphrase when prompted to.&lt;/p&gt;
&lt;p&gt;I now have an ansible playbook I can use to quickly spin up a new Proxmox host on a fresh Debian with an encrypted disk! This is a solid foundation for a flexible homelab. I will be able to spin up a long-lived VM for my main k3s node. I will be able to spin up additional k3s workers if need be, or an entirely different cluster to play with, all while keeping my production reasonably isolated and stable.&lt;/p&gt;
&lt;p&gt;We&apos;ll see in another blog post how to use opentofu, cloud-init and ansible to spin up new VMs on that Proxmox host!&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Massive thanks to my colleagues and friends &lt;a href=&quot;https://half-shot.uk/&quot;&gt;Half-Shot&lt;/a&gt;, Davide, and Ark for their insights!&lt;/em&gt;&lt;/p&gt;
</content:encoded></item></channel></rss>