tag:gabnotes.org,2005:/posts_feedGab's Noteshttps://cdn.u.pika.page/MkkKHhFVhYyoeryzTxmyCjX3AMjuBvANZ7lh7rhuvZ8/fn:avatar_nain_high/plain/s3://pika-production/lqku7ak0ay2kq9u42m8iac5vle8d2025-11-28T09:30:06Ztag:gabnotes.org,2005:Post/728702025-11-28T09:30:06Z2026-02-24T13:54:53Zdocker: DNS resolution failure<div class="trix-content">
<p>My docker containers suddenly were unable to resolve DNS names, which blocked them from accessing the remote database they need.</p>
<p>The symptoms were various but in the end the containers failed to start.</p>
<p>Go apps had a log similar to this:</p>
<pre class="hljs-highlight"><code class="language-plaintext">dial tcp: lookup db.example.com on 127.0.0.11:53: server misbehaving</code></pre>
<p>Python apps were outputing large stack traces, and so on.</p>
<p>The fix was to completely remove the container (<code>docker compose down</code>) and re-create it. Stopping and restarting it was not enough.</p>
<p>I still don’t know the root cause, but the incident is over 😮💨</p>
</div>
My docker containers suddenly were unable to resolve DNS names, which blocked them from accessing the remote database they need. The symptoms were various but in the end the containers...tag:gabnotes.org,2005:Post/726482025-11-25T18:35:48Z2025-11-25T18:35:48ZConvert HEIC (iPhone) pictures to JPG on CLI<div class="trix-content">
<p>When handling pictures from iOS devices on other operating systems, we often see the infamous <code>.heic</code> extension. Some web services like ones who offer to produce a photo book or calendar and which are overly popular around the winter festivities only accept JPEG files.</p>
<p>Here’s a quick snippet for converting these HEIC files to JPEG (it’s <code>fish</code> but bash or other shells probably provide similar functionalities):</p>
<pre class="hljs-highlight"><code class="language-bash"><span class="hljs-keyword">for</span> f <span class="hljs-keyword">in</span> *.heic
heif-convert <span class="hljs-string">"<span class="hljs-variable">$f</span>"</span> <span class="hljs-string">"<span class="hljs-subst">$(path change-extension 'jpg' $f)</span>"</span>
end</code></pre>
<p>It depends on <code>heif-convert</code>, which is provided by <code>libheif-tools</code> on Fedora.</p>
<pre class="hljs-highlight"><code class="language-plaintext">dnf install libheif-tools</code></pre>
<p>If you have the relevant commands for bash/zsh and other distros, please send an email so I can update this article 😊</p>
</div>
When handling pictures from iOS devices on other operating systems, we often see the infamous .heic extension. Some web services like ones who offer to produce a photo book or calendar...tag:gabnotes.org,2005:Post/713002025-11-10T13:33:09Z2025-11-16T18:08:08ZI tried moving to GrapheneOS<div class="trix-content">
<blockquote>
<p>🏃🏻 On a rush? Don’t want to read the whole piece? Don’t bother asking an AI to summarize! Read the TL;DR:</p>
<p>I tried moving from an iPhone 15 Pro to a Pixel 9 Pro with GrapheneOS. Most things worked fine. Others like family photo sharing and calendar sharing were clunky and/or required server-side components that I didn’t want to maintain. Still others like mobile payment or AirTag-like devices just didn’t work. So I moved back, glad I did the experience.</p>
</blockquote>
<h2 id="-context">
<a href="proxy.php?url=#-context" class="anchor" title="Link to this heading"></a>📖 Context</h2>
<p>I’ve been using iOS on mobile since the iPhone 5 came out in 2012. In June 2025, after nearly thirteen years, I wanted to try something else. I wanted to check whether <a href="proxy.php?url=https://www.youtube.com/watch?v=cTLTG4FTNBQ">the grass was greener over there</a>. “Over there” being Android, since I wasn’t ready to jump to what I feel are more experimental mobile OSes like postmarketOS.</p>
<p>The Android space is largely dominated by Google. Leaving Apple for Google wasn’t in my plans, I’ve already picked my poison between the two. There are however alternative builds made by privacy/security-conscious people, and that’s what I wanted to try. After doing some research, I decided on GrapheneOS since it resonates most with my values. It’s only compatible with Google Pixel devices, so I needed one.</p>
<p>At that time, my phone was an iPhone 15 Pro. The latest generation of iPhone was 16, so I had a very recent device. I didn’t want to compare apples to oranges and be biased with an old/slow device, so I wanted to get my hands on a recent Pixel. There were sales running at a cell phone provider, so I went to a shop and grabbed a Pixel 9 Pro — the latest generation available.</p>
<p>At home, I flashed it with GrapheneOS, tried it as a secondary device for a week then switched to it full time. I had to notify my iMessage groups that they wouldn’t be able to reach me anymore. Fortunately, I didn’t have many and it wasn’t very difficult to convince them to use Signal instead.</p>
<p>After a month of use, I decided to go back to iPhone and iOS.</p>
<h2 id="-what-worked-well">
<a href="proxy.php?url=#-what-worked-well" class="anchor" title="Link to this heading"></a>👌🏻 What worked well</h2>
<p>First, I want to mention that GrapheneOS works just fine — and that’s what I expect of my phone. I was initially afraid that I wouldn’t be able to use some of the apps I needed (e.g. banks), but it turned out OK.</p>
<h3 id="hardware">
<a href="proxy.php?url=#hardware" class="anchor" title="Link to this heading"></a>Hardware</h3>
<p>The Pixel’s photo module combined to the Pixel Camera app makes gorgeous photos. The 5x lens is especially awesome.</p>
<p>The battery life was good. I expect my phone to last for a day at least, and GrapheneOS on Pixel 9 Pro passed with flying colors. I had several occasions to use the camera more intensively and even on these days I didn’t need to charge before the night. It also didn’t heat that much, at least not at levels or places that were disturbing during use.</p>
<p>The fingerprint reader under the screen worked fine. It produced some false negatives but not enough to be annoying. It took a bit of time to get used to the location and to the fact that I needed to use my finger to authenticate (recent iPhones have hardware for face authentication) but it was mostly a smooth ride after that.</p>
<p>I have several MagSafe chargers around the house, and the case I picked for the Pixel is compatible with them, so I could continue using them.</p>
<h3 id="apps">
<a href="proxy.php?url=#apps" class="anchor" title="Link to this heading"></a>Apps</h3>
<p>The community is very open, with forums and GitHub issues for example. Lots of literature online.</p>
<p>Alternative app stores are available, mostly around free software.</p>
<p>There are free (as in free speech) apps for many things.</p>
<p>I live in Lyon (France) and they have an Android app to store public transportation tickets on the phone. This just doesn’t exist on iOS.</p>
<p>KDEConnect works fine with my Linux device, allowing me to share a clipboard and share files between my laptop and phone. It had a few disconnects, but I attribute them to KDEConnect itself and not GrapheneOS.</p>
<h3 id="os">
<a href="proxy.php?url=#os" class="anchor" title="Link to this heading"></a>OS</h3>
<p>Android has a notion of “Professional profile” on which you can install apps that you don’t want running 100% of the time. This profile can be turned off which causes all apps in the profile to be “paused”. They don’t work in background or send notifications. Useful for various apps such as work chat, email, calendar, etc. that I could turn on only during work time and disable when going home.</p>
<p>Many apps require some form of Google Play services running on the phone. GrapheneOS chooses to allow the user to run these Play services sandboxed, as any other app on the phone. I found it worked quite well.</p>
<p>Notifications management on Android is really nice. If the app plays nicely, the OS can discriminate between different categories of notifications and let the user chose in the system settings how they want to handle them. This removes a burden on the app developers because they don’t have to develop a UI for this, and gives more power to the user.</p>
<p>Visual voicemail worked in the Google Phone app (not in the default phone app).</p>
<h2 id="-what-didnt-work-so-well">
<a href="proxy.php?url=#-what-didnt-work-so-well" class="anchor" title="Link to this heading"></a>👎🏻 What didn’t work so well</h2>
<h3 id="photo-library">
<a href="proxy.php?url=#photo-library" class="anchor" title="Link to this heading"></a>Photo library</h3>
<p>My wife has an iPhone as well and we have an iCloud Shared Photo Library setup so the phone automatically shares photos we take at home or when we’re together. This avoids having to store them twice, and avoids constants “could you send me the pictures you took tonight?”. This iCloud feature is obviously reserved for iOS devices. We grew accustomed to it however, so I needed to find a solution.</p>
<p>Enter <a href="proxy.php?url=https://immich.app/">immich</a>. They describe themselves as a “self-hosted photo and video management solution”. They have a server component to host and process photos, and mobile apps for Android and iOS to upload them automatically and access them. The UI is similar to Google Photo (which I believe is a good thing).</p>
<p>Immich has a <a href="proxy.php?url=https://docs.immich.app/features/partner-sharing/">partner sharing</a> feature which works similar to the iCloud Shared Photo Library except that it’s all or nothing: you can’t decide which pictures to share.</p>
<p>I set it up on my VPS and re-uploaded all my iPhone photos so they could be processed. This took some time but ended up working. I also setup my wife’s phone similarly.</p>
<p>In the end, we had a working solution but not as good as what we had on iOS:</p>
<ul>
<li><p>we couldn’t decide which pictures to share or keep to ourselves</p></li>
<li><p>the app wasn’t as integrated to the OS (but worked well)</p></li>
<li><p>most importantly: it was yet another thing I had to maintain server-side, and <a href="proxy.php?url=https://gabnotes.org/posts/moving-the-home-server-to-a-vps">as I mentioned before</a>, I’m trying to avoid this trajectory.</p></li>
</ul>
<h3 id="tracking-devices-airtag-like">
<a href="proxy.php?url=#tracking-devices-airtag-like" class="anchor" title="Link to this heading"></a>Tracking devices (AirTag-like)</h3>
<p>I have several AirTags for things that I don’t want to lose: my keys, badge, etc. These work only with iPhones so I wanted to try an alternative. I already knew about Pebblebee because I have one of their “card” devices to slip in my wallet. They offer rechargeable devices where most of their competitors sell devices with non-replaceable or rechargeable battery — basically e-waste.</p>
<p>So I purchased a few.</p>
<p>Unfortunately, GrapheneOS doesn’t support Google’s Find My Device network and Pebblebee discontinued their own tracking app for new devices, so I was left without a solution.</p>
<h3 id="tracking-the-phone">
<a href="proxy.php?url=#tracking-the-phone" class="anchor" title="Link to this heading"></a>Tracking the phone</h3>
<p>I’m used to be able to locate my phone if I forget it somewhere. Since Google’s Find My Device was out of the equation, I searched for an alternative. I found <a href="proxy.php?url=https://fmd-foss.org/">FMD</a>. It works quite well, I was able to track my device when on, and I could even take a picture of the cameras. However, if the phone got stolen, the first thing the thieve would do is turn off the phone or restart it. And in that case, FMD becomes useless because the app only restarts when unlocking the phone after booting (unless the device is rooted, which I don’t wanted).</p>
<p>Also, it was Yet Another Thing I Had To Maintain™ server-side.</p>
<h3 id="mobile-payment">
<a href="proxy.php?url=#mobile-payment" class="anchor" title="Link to this heading"></a>Mobile payment</h3>
<p>GrapheneOS doesn’t support Google Wallet, so users rely on banks providing a dedicated app for mobile payment. My main bank does, no issue there. However, my meal card and other banks don’t, so I have to carry around my wallet with me. At this point, I’m so used to be able to pay with my phone that this is a real annoyance.</p>
<h3 id="calendar-and-contacts-sync">
<a href="proxy.php?url=#calendar-and-contacts-sync" class="anchor" title="Link to this heading"></a>Calendar and contacts sync</h3>
<p>I didn’t want to give Google my calendars and contacts again, and I couldn’t sync iCloud data. I worked around using <a href="proxy.php?url=https://radicale.org/v3.html">radicale</a>, and it worked fine but it was Yet Another Thing I Had To Maintain™.</p>
<p>Also, calendar sharing on radicale was fun: I had to symlink a file between two directories on disk.</p>
<h3 id="other-minor-annoyances">
<a href="proxy.php?url=#other-minor-annoyances" class="anchor" title="Link to this heading"></a>Other minor annoyances</h3>
<ul>
<li><p>The default keyboard wasn’t to my taste (but I found alternatives).</p></li>
<li><p>No gesture for “Forward” in Firefox. In Safari on iOS, swiping left to right calls the previous page and right to left calls the next page. On Android, both these gestures are for “Back”.</p></li>
<li><p>No builtin reliable backup system. Seedvault is builtin but apparently isn’t that reliable.</p></li>
<li><p>I found the emojis ugly.</p></li>
<li><p>Some AirPods Pro feature, like conversation detection, don’t work. Could probably be worked around with a more Android-friendly device.</p></li>
<li><p>The wallabag app doesn’t support 2FA on Android.</p></li>
<li><p>I was unable to print a shipping label correctly from the phone. I never had any issue on iOS. I tried system defaults and several apps, no dice. The orientation was always wrong and the label only printed partially.</p></li>
<li><p>Opsgenie on Android handles urgent notifications badly — a pity given that’s their core business.</p></li>
<li><p>Face unlock is not available. It is on Google’s Android but it only uses the camera which can apparently easily be tricked so GrapheneOS disables it.</p></li>
</ul>
<h2 id="-conclusion">
<a href="proxy.php?url=#-conclusion" class="anchor" title="Link to this heading"></a>🪃 Conclusion</h2>
<p>I’m glad I was able to test things out. The Pixel 9 Pro is a good device, and GrapheneOS is a good OS — they’re just not for me right now. You can absolutely make it work but I feel like it requires more energy than I have available now, so I decided to go back to iOS.</p>
<p>I sold the Pixel and its accessories for 30€ less than I purchased them a month earlier, not a bad deal in the end. It’s like I rented a phone for 1€/day.</p>
<p>iOS doesn’t align with my free software values, but for now it’s a tool that gets the job done and gets out of the way. I don’t want my phone to be a project, I have enough of those.</p>
<p>If you have the means — either financially or if you find a cheap decent device — I encourage you to follow a similar experience! It was enriching. At first I really thought it would stick, but it turned out otherwise.</p>
</div>
“🏃🏻 On a rush? Don’t want to read the whole piece? Don’t bother asking an AI to summarize! Read the TL;DR: I tried moving from an iPhone 15 Pro to a Pixel...tag:gabnotes.org,2005:Post/652552025-08-09T15:26:32Z2025-11-16T17:30:49ZCloud setup pricing update<div class="trix-content">
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--jpg">
<img height="3327" width="4990" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/WFAzqUbBsv_AyV8ETCCh4YVH9WsRJimhLyuaW_FUQWU/s:3840:3840/fn:micheile-henderson-ZVprbBmT8QA-unsplash%281%29/plain/s3://pika-production/ohaaunqtrecjgow6lesgvy6vqteg" data-original-src="proxy.php?url=https://cdn.u.pika.page/rlg_xRQltV1chRB5-2qM88jXwmt4a5PzAYMY1qcuvp4/fn:micheile-henderson-ZVprbBmT8QA-unsplash%281%29/plain/s3://pika-production/ohaaunqtrecjgow6lesgvy6vqteg" alt="" src="proxy.php?url=https://cdn.u.pika.page/USC3lxVXvDi4Q2X5Ou1ICZD8afGPY3egpZhoYtU0TSM/s:1800:1400/fn:micheile-henderson-ZVprbBmT8QA-unsplash%281%29/plain/s3://pika-production/ohaaunqtrecjgow6lesgvy6vqteg">
<figcaption class="attachment__caption" aria-hidden="true">
Photo by <a href="proxy.php?url=https://unsplash.com/@micheile?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">micheile henderson</a> on <a href="proxy.php?url=https://unsplash.com/photos/green-plant-in-clear-glass-vase-ZVprbBmT8QA?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
</figcaption>
</figure></div>
<blockquote><p>💡 OVHcloud is my current employer. This is my opinion, as a customer, on a service they provide.</p></blockquote>
<h2 id="-initial-cost">
<a href="proxy.php?url=#-initial-cost" class="anchor" title="Link to this heading"></a>⏮️ Initial cost</h2>
<p>When <a href="proxy.php?url=https://gabnotes.org/posts/moving-the-home-server-to-a-vps">moving my home server to a VPS</a>, I made some calculations on the cost. Here is what I wrote at the time:</p>
<blockquote>
<p>With the VPS, here's the cost breakdown (including 20% VAT in France):</p>
<ul>
<li><p>VPS: 13.56€/mo</p></li>
<li><p>Automated backup option: 3.96€/mo</p></li>
<li><p>MySQL: 7.908€/mo</p></li>
<li><p>PostgreSQL: 7.908€/mo</p></li>
<li><p><strong>Total</strong>: 33.336€/mo or 400.032€/year</p></li>
</ul>
<p>I could save some money - about 30€/year - by committing to the VPS for one or two years but I'm giving myself some time before doing that.</p>
<p>[…]</p>
<p>The MySQL DB is used only for three instances of Ghost (open source blog software). I'm looking at alternative solutions for the blogs. Removing MySQL would save me ~96€/year so the recurring cost would go even below the homemade setup.</p>
</blockquote>
<h2 id="-new-offer">
<a href="proxy.php?url=#-new-offer" class="anchor" title="Link to this heading"></a>⏩ New offer</h2>
<p>Fast forward to now, I’ve made a few changes:</p>
<ul>
<li><p>Thanks to a <a href="proxy.php?url=https://www.ovhcloud.com/fr/vps/">new VPS offer</a> at OVHcloud, I now pay 5.39€/mo for more resources (4 CPU and 8 GB RAM)</p></li>
<li><p>Automated backups are now included</p></li>
<li><p>I moved my blogs over to <a href="proxy.php?url=https://gabnotes.org/posts/hello-pika">Pika</a>, which costs 4.33€/mo ($60/yr), and I was able to shut down the MySQL instance</p></li>
<li><p>I still use a Web Cloud Database PostgreSQL instance: 7.91€/mo</p></li>
<li><p><strong>Total</strong>: 17.63€/mo or 211.56€/yr</p></li>
</ul>
<p>This is less than the ~334€/yr I estimated for the home setup, and nearly half of the initial cloud cost. I have enough resources for my current needs, and I can even scale vertically: the next plan gives me 50% more CPU and RAM for +3€/mo, still well within reason.</p>
<p>Committing for 12 months now looks far less interesting: I would only save 10€ over the year.</p>
<h2 id="-opinion">
<a href="proxy.php?url=#-opinion" class="anchor" title="Link to this heading"></a>🧐 Opinion</h2>
<p>So far, I’m glad! I initially started renting my VPS at OVHcloud because it’s a French company 🇫🇷🐔 and because I wanted control on my data, but the price was steep compared to competition. I’m now also staying for the price. Their offer is quite competitive now!</p>
<p>The migration was not perfectly smooth. I had to order a new service and reinstall everything from scratch. There was an option for me to upgrade to the new offer at the same cost but I wouldn’t get as much as the new offer advertised for that price, and I wasn’t presented with an option to stay on a similar resource level and reduce the monthly fee. See the images below for context.</p>
<div class="attachment-gallery attachment-gallery--4">
<figure class="attachment attachment--preview attachment--png">
<img height="746" width="269" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/HZAFovZKa0STY-_l9O5zPG_wz0kJQtrGutmuq5c0qVY/s:3840:3840/fn:image/plain/s3://pika-production/taq98rxzwy090p7hed2uadql28la" data-original-src="proxy.php?url=https://cdn.u.pika.page/DyV1boMKqykar-3aCdm1ELagPR1gdUJCuL7CmlG--7U/fn:image/plain/s3://pika-production/taq98rxzwy090p7hed2uadql28la" alt="" src="proxy.php?url=https://cdn.u.pika.page/dC2jyhOaRIp3o2v_Pfx_E6fMuBirHQsqnOektpSyYqM/s:1800:1400/fn:image/plain/s3://pika-production/taq98rxzwy090p7hed2uadql28la">
<figcaption class="attachment__caption" aria-hidden="true">
The plan I chose in the new offer: 4 CPU and 8GB RAM is priced at 3.82€/mo (excl. taxes and with commitment)
</figcaption>
</figure><figure class="attachment attachment--preview attachment--png">
<img height="718" width="1227" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/x0s4aUfICnsUJhA3A13kRa_1kOZZq4M4M1EulTu_i30/s:3840:3840/fn:Copie%20d%27%C3%A9cran_20250807_210718/plain/s3://pika-production/1hj6rrvlqsvip1yh3gmztpfkbv97" data-original-src="proxy.php?url=https://cdn.u.pika.page/09SvMsAcxtcIvgNSN50Bdv7yoaPZFXTxzQwoHZyBzao/fn:Copie%20d%27%C3%A9cran_20250807_210718/plain/s3://pika-production/1hj6rrvlqsvip1yh3gmztpfkbv97" alt="" src="proxy.php?url=https://cdn.u.pika.page/HXqDTKOL5EYTn4sY5HkR7F43uVj7X8zm9p-IEmJqKXk/s:1800:1400/fn:Copie%20d%27%C3%A9cran_20250807_210718/plain/s3://pika-production/1hj6rrvlqsvip1yh3gmztpfkbv97">
<figcaption class="attachment__caption" aria-hidden="true">
Upgrade screen: on the left, the configuration I had before with 4 CPU and 8GB RAM for 24.20€/mo and on the right 6 CPU and 12GB RAM for the same price.
</figcaption>
</figure><figure class="attachment attachment--preview attachment--png">
<img height="742" width="264" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/OOu7A2nFKQxcTAoJL6ejlZnttArR5gqwnLiWYkbnZAg/s:3840:3840/fn:image/plain/s3://pika-production/34a44rbnj4zgvg7j25a01t6ml3qu" data-original-src="proxy.php?url=https://cdn.u.pika.page/pO1Wm11cprdE0GAzRLOTIkHrCkSnpixdnsvxDWSgq7I/fn:image/plain/s3://pika-production/34a44rbnj4zgvg7j25a01t6ml3qu" alt="" src="proxy.php?url=https://cdn.u.pika.page/iDf5TcM5E6ri21GHpU_lmXvMa4eHT39hOFohhqtn8CE/s:1800:1400/fn:image/plain/s3://pika-production/34a44rbnj4zgvg7j25a01t6ml3qu">
<figcaption class="attachment__caption" aria-hidden="true">
The plan suggested by the upgrade screen: 6 CPU and 12GB RAM, priced at 5.95€/mo for new customers but 24.20€/mo when upgrading.
</figcaption>
</figure><figure class="attachment attachment--preview attachment--png">
<img height="742" width="262" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/O9aJvEt-OPcAWUvgHH3pJTfhX8poygTM1kOxUsLf2mQ/s:3840:3840/fn:image/plain/s3://pika-production/uz09hke0hztxz1ox0ezaczdn2zou" data-original-src="proxy.php?url=https://cdn.u.pika.page/VaUM9SRf23vZ2qAcHbxEqqAyAWJvFQdoai-vG0J2vIs/fn:image/plain/s3://pika-production/uz09hke0hztxz1ox0ezaczdn2zou" alt="" src="proxy.php?url=https://cdn.u.pika.page/KDI3YLgM50UTRF5JTrtpuQzhAOk3rj0R30qQoIsdcZo/s:1800:1400/fn:image/plain/s3://pika-production/uz09hke0hztxz1ox0ezaczdn2zou">
<figcaption class="attachment__caption" aria-hidden="true">
For 21.25€/mo on the new offer, I could get as much as 12 CPU and 48GB RAM: twice the CPU count and 4x the RAM.
</figcaption>
</figure>
</div>
<p>Thankfully, I already had most of the setup process automated, including application deployment and DNS record management, so migrating wasn’t too difficult. All applications using a database were already using the managed instance outside the VPS. I had to transfer a few files but nothing that <code>scp</code> and <code>rsync</code> couldn’t handle. I regret the marketing move though, it doesn’t seem very fair.</p>
<p>I may investigate in using S3 as a backend for file-based applications, to make the VPS deployments truly stateless. I’m not sure yet. Stay tuned! 😉</p>
</div>
“💡 OVHcloud is my current employer. This is my opinion, as a customer, on a service they provide.” ⏮️ Initial costWhen moving my home server to a VPS, I made some calculations...tag:gabnotes.org,2005:Post/641932025-07-28T20:05:06Z2025-11-16T17:30:53ZCustom Terraform linter rules with Rego<div class="trix-content">
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--jpg">
<img height="1836" width="3264" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/yXDVWyIwzpRCYAxpzcmzTWLxgi3Q1QBhyA15eAMXpr4/s:3840:3840/fn:lucas-gallone-9Mq_Q-4gs-w-unsplash/plain/s3://pika-production/n58tea65dvh1e9zvvkxpfqxjtp89" data-original-src="proxy.php?url=https://cdn.u.pika.page/FR9_T7Pum90yZnzbBfM-eQSKXtMBb2Fv7hQCmmc0B1c/fn:lucas-gallone-9Mq_Q-4gs-w-unsplash/plain/s3://pika-production/n58tea65dvh1e9zvvkxpfqxjtp89" alt="" src="proxy.php?url=https://cdn.u.pika.page/xp-PO2OXL_jMImd9e_gghO7nuGhnbYqLlCInKxoZrFc/s:1800:1400/fn:lucas-gallone-9Mq_Q-4gs-w-unsplash/plain/s3://pika-production/n58tea65dvh1e9zvvkxpfqxjtp89">
<figcaption class="attachment__caption" aria-hidden="true">
Photo from <a href="proxy.php?url=https://unsplash.com/fr/@lucasgallone?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Lucas Gallone</a> on <a href="proxy.php?url=https://unsplash.com/fr/photos/photo-en-gros-plan-de-balle-de-foin-9Mq_Q-4gs-w?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
</figcaption>
</figure></div>
<h2 id="context">
<a href="proxy.php?url=#context" class="anchor" title="Link to this heading"></a>Context</h2>
<p>Following my <a href="proxy.php?url=https://gabnotes.org/posts/reduce-terraform-plan-time-when-using-modules">Terraform module adventures</a>, I wanted to detect potential duplicates in our numerous ACLs, because they would only produce errors during application of the plan, after merging. And relying on humans to find a needle in the proverbial haystack wasn’t going to scale. I needed to automate this.</p>
<h2 id="duplicate-you-say">
<a href="proxy.php?url=#duplicate-you-say" class="anchor" title="Link to this heading"></a>Duplicate you say?</h2>
<p>Two ACLs are considered duplicates if they share the same source (token) and destination (endpoint).</p>
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--png">
<img height="552" width="736" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/KCmAzc1N5YQIGXwWb7e4rEflF_lRTUlmuq1mcYYS3zQ/s:3840:3840/fn:image/plain/s3://pika-production/alp83m64qaj7phrnyo99eae99d3j" data-original-src="proxy.php?url=https://cdn.u.pika.page/Mp-XuQQyc9789MUIQftJ9zkEaV2VSgyVpVFAR9Ep94A/fn:image/plain/s3://pika-production/alp83m64qaj7phrnyo99eae99d3j" alt="Meme representing two spidermen pointing at each other, no caption." src="proxy.php?url=https://cdn.u.pika.page/hEudr8FfZueCQ4Yo-x9CHQHPcPIvSl0txlkJAWnrZ5s/s:1800:1400/fn:image/plain/s3://pika-production/alp83m64qaj7phrnyo99eae99d3j">
</figure></div>
<p>Our module is called like this:</p>
<pre class="hljs-highlight"><code class="language-plaintext">module "acl_foo" {
source = "../../modules/acl"
token = "token"
endpoint = "endpoint"
# ...
}</code></pre>
<p>We want to detect multiple instances of this module with the same source and destination, and raise an error.</p>
<p>We also want to detect duplicates for <code>gateway_policy</code>s declared manually, without using the module.</p>
<pre class="hljs-highlight"><code class="language-plaintext">resource "arsenal_gateway_policy" "foo" {
token = "token"
endpoint = "endpoint"
# ...
}</code></pre>
<h2 id="linters-to-the-rescue">
<a href="proxy.php?url=#linters-to-the-rescue" class="anchor" title="Link to this heading"></a>Linters to the rescue</h2>
<p>Enter <code>tflint</code>. We already use it to make sure our code follows common best practices, and it advertises itself as “pluggable”. Unfortunately, it requires writing a separate Go program using a specific library, creating build binaries, releases, etc.</p>
<p>Go is in our tool set, but I had something much simpler in mind. Ideally, I could package everything in our Terraform repository to avoid having a separate thing to maintain.</p>
<p><code>tflint</code> promotes another option with the <code>opa</code> <a href="proxy.php?url=https://github.com/terraform-linters/tflint-ruleset-opa/">plugin</a>. It allows you to write custom policies in a language called <a href="proxy.php?url=https://www.openpolicyagent.org/docs/policy-language">Rego</a>. It also provides custom functions to retrieve data from your Terraform definitions. We can also write tests for our rules.</p>
<h2 id="the-rule">
<a href="proxy.php?url=#the-rule" class="anchor" title="Link to this heading"></a>The rule</h2>
<p>Here is the rule I came up with for our duplicate detection. I’m no expert in Rego nor Terraform, so if you see something that I could improve, please let me know! The rest of this section is dedicated to explaining every step.</p>
<pre class="hljs-highlight"><code class="language-plaintext">package tflint
import rego.v1
resource_acls := terraform.resources("arsenal_gateway_policy", {"token": "string", "endpoint": "string"}, {})
module_acls := terraform.module_calls({"token": "string", "endpoint": "string"}, {})
all_acls := array.concat(resource_acls, module_acls)
# filter out resources and modules
# which don't have a token or endpoint
# or which have undefined ones
# as we can't check for duplicates in this case.
acls := [
acl |
acl := all_acls[_]
"token" in object.keys(acl.config)
not acl.config.token.unknown
"endpoint" in object.keys(acl.config)
not acl.config.endpoint.unknown
]
acl_pairs := [
acl_string |
acl := acls[_]
acl_string := concat(" -> ", [acl.config.token.value, acl.config.endpoint.value])
]
acl_duplicates := {
i |
pair := acl_pairs[i]
count([x | some x in acl_pairs; x == pair]) > 1
}
deny_duplicate_acl contains issue if {
_ := acl_duplicates[i]
issue := tflint.issue(`Duplicate ACL found`, acls[i].decl_range)
}
</code></pre>
<p>The <a href="proxy.php?url=https://github.com/terraform-linters/tflint-ruleset-opa/blob/main/docs/intro.md">introduction documentation</a> discusses the package and import lines, so let’s move on.</p>
<hr>
<pre class="hljs-highlight"><code class="language-plaintext">resource_acls := terraform.resources("gateway_policy", {"token": "string", "endpoint": "string"}, {})
module_acls := terraform.module_calls({"token": "string", "endpoint": "string"}, {})
all_acls := array.concat(resource_acls, module_acls)</code></pre>
<p><code>terraform.resources</code> and <code>terraform.module_calls</code> are documented <a href="proxy.php?url=https://github.com/terraform-linters/tflint-ruleset-opa/blob/main/docs/functions.md#terraformmodule_calls">here</a>. They return a list of resources of the given type or module calls, and we request the <code>token</code> and <code>endpoint</code> values. We then concatenate everything to the <code>all_acls</code> list.</p>
<hr>
<pre class="hljs-highlight"><code class="language-plaintext"># filter out resources and modules
# which don't have a token or endpoint
# or which have undefined ones
# as we can't check for duplicates in this case.
acls := [
acl |
acl := all_acls[_]
"token" in object.keys(acl.config)
not acl.config.token.unknown
"endpoint" in object.keys(acl.config)
not acl.config.endpoint.unknown
]</code></pre>
<p>We filter out the items we can’t work with: the modules which don’t have a token or endpoint (we have other module calls), and the ACLs for which one of the values is unknown. This can happen if one of them is defined as the output of another module, resource or data block for example, and in this case we have very little to work with, so we chose to exclude them.</p>
<hr>
<pre class="hljs-highlight"><code class="language-plaintext">acl_pairs := [
acl_string |
acl := acls[_]
acl_string := concat(" -> ", [acl.config.token.value, acl.config.endpoint.value])
]</code></pre>
<p>We use a list comprehension to transform this list of modules to a list of strings containing the token and endpoint of the modules. We end up with something like this:</p>
<pre class="hljs-highlight"><code class="language-json"><span class="hljs-punctuation">[</span>
<span class="hljs-string">"token:foo -> endpoint:foo"</span><span class="hljs-punctuation">,</span>
<span class="hljs-string">"token:bar -> endpoint:foo"</span><span class="hljs-punctuation">,</span>
<span class="hljs-string">"token:foo -> endpoint:foo"</span>
<span class="hljs-punctuation">]</span></code></pre>
<hr>
<pre class="hljs-highlight"><code class="language-plaintext">acl_duplicates := {
i |
pair := acl_pairs[i]
count([x | some x in acl_pairs; x == pair]) > 1
}</code></pre>
<p>Finally, we use a set comprehension to collect all indices of ACL pairs that appear at least two times in the <code>module_acl_pairs</code> list.</p>
<p>The result following our previous example would be: <code>{0, 2}</code>.</p>
<hr>
<pre class="hljs-highlight"><code class="language-plaintext">deny_duplicate_acl contains issue if {
_ := acl_duplicates[i]
issue := tflint.issue(`Duplicate ACL found`, acls[i].decl_range)
}</code></pre>
<p>In the end, we declare our rule, which produces an issue for every item in <code>module_acl_duplicates</code>. It uses the indices stored in this list to retrieve the original module calls saved in <code>module_acls</code> in order to fetch their <code>decl_range</code>, which allows us to produce a nice error message pointing directly at the relevant source code.</p>
<p>One thing that is only written deep in the “introduction” docs of the plugin and which cost me quite some time was the fact that <a href="proxy.php?url=https://github.com/terraform-linters/tflint-ruleset-opa/blob/648261bd2f5071dcfad961ffe2b7cf1e9a04b997/docs/intro.md?plain=1#L53">rules must conform to a specific naming scheme</a>. Specifically, the rule name must start with a given string to be interpreted, in our case we chose <code>deny_</code>.</p>
<hr>
<p>We enable the plugin like so:</p>
<pre class="hljs-highlight"><code class="language-plaintext">plugin "opa" {
enabled = true
version = "0.9.0"
source = "github.com/terraform-linters/tflint-ruleset-opa"
}</code></pre>
<p>And finally run <code>tflint</code>!</p>
<pre class="hljs-highlight"><code class="language-console"><span class="hljs-meta prompt_">$ </span><span class="bash"><span class=<span class="hljs-string">"bash"</span>><span class=<span class="hljs-string">"bash"</span>><span class=<span class="hljs-string">"bash"</span>>tflint</span></span></span></span>
2 issue(s) found:
Error: Duplicate ACL found (opa_deny_duplicate_acl)
on envs/prod/acl.tf line 97:
97: module "acl_foo" {
Reference: ../../.tflint.d/policies/duplicate_acls.rego:31
Error: Duplicate ACL found (opa_deny_duplicate_acl)
on envs/prod/acl.tf line 111:
111: resource "gateway_policy" "bar" {
Reference: ../../.tflint.d/policies/duplicate_acls.rego:31</code></pre>
<h2 id="tests">
<a href="proxy.php?url=#tests" class="anchor" title="Link to this heading"></a>Tests</h2>
<p>This rule can be tested using the facilities <a href="proxy.php?url=https://github.com/terraform-linters/tflint-ruleset-opa/blob/main/docs/testing.md">provided by the plugin</a>. We test that we detect duplicates across resources, modules, a mix of both, and that we don’t report errors when everything is fine.</p>
<pre class="hljs-highlight"><code class="language-plaintext">duplicate_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": `
resource "arsenal_gateway_policy" "duplicate_1" {
token = "toto"
endpoint = "tata"
}
resource "arsenal_gateway_policy" "duplicate_2" {
token = "toto"
endpoint = "tata"
}
`})
test_deny_duplicate_acl_resource_failed if {
issues := deny_duplicate_acl with terraform.resources as duplicate_resources
count(issues) == 2
issue := issues[_]
issue.msg == `Duplicate ACL found`
}</code></pre>
<pre class="hljs-highlight"><code class="language-plaintext">duplicate_modules(schema, options) := terraform.mock_module_calls(schema, options, {"main.tf": `
module "acl_duplicate_1" {
token = "tete"
endpoint = "tata"
}
module "acl_duplicate_2" {
token = "tete"
endpoint = "tata"
}
`})
test_deny_duplicate_acl_module_failed if {
issues := deny_duplicate_acl with terraform.module_calls as duplicate_modules
count(issues) == 2
issue := issues[_]
issue.msg == `Duplicate ACL found`
}</code></pre>
<pre class="hljs-highlight"><code class="language-plaintext">unique_resources_overlap_modules(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": `
resource "arsenal_gateway_policy" "duplicate_1" {
token = "toto"
endpoint = "tata"
}
resource "arsenal_gateway_policy" "duplicate_2" {
token = "tata"
endpoint = "tata"
}
`})
unique_modules_overlap_resources(schema, options) := terraform.mock_module_calls(schema, options, {"main.tf": `
module "acl_unique_1" {
token = "tata"
endpoint = "tata"
}
module "acl_unique_1" {
token = "titi"
endpoint = "tata"
}
`})
test_deny_duplicate_acl_resource_module_failed if {
issues := deny_duplicate_acl with terraform.resources as unique_resources_overlap_modules
with terraform.module_calls as unique_modules_overlap_resources
count(issues) == 2
issue := issues[_]
issue.msg == `Duplicate ACL found`
}</code></pre>
<pre class="hljs-highlight"><code class="language-plaintext">unique_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": `
resource "arsenal_gateway_policy" "unique_1" {
token = "toto"
endpoint = "tata"
}
resource "arsenal_gateway_policy" "unique_2" {
token = "titi"
endpoint = "tata"
}
`})
unique_modules(schema, options) := terraform.mock_module_calls(schema, options, {"main.tf": `
module "acl_unique_1" {
token = "tete"
endpoint = "tata"
}
module "acl_unique_1" {
token = "tutu"
endpoint = "tata"
}
`})
test_deny_duplicate_acl_passed if {
issues := deny_duplicate_acl with terraform.resources as unique_resources
with terraform.module_calls as unique_modules
count(issues) == 0
}</code></pre>
<p>Tests can be run with a special environment variable before the <code>tflint</code> call.</p>
<pre class="hljs-highlight"><code class="language-console">TFLINT_OPA_TEST=1 tflint</code></pre>
<h2 id="pre-commit-and-multiple-root-modules">
<a href="proxy.php?url=#pre-commit-and-multiple-root-modules" class="anchor" title="Link to this heading"></a><code>pre-commit</code> and multiple root modules</h2>
<p>We have two more constraints:</p>
<ul>
<li><p>we run <code>tflint</code> through <code>pre-commit</code>, using <a href="proxy.php?url=https://github.com/antonbabenko/pre-commit-terraform">antonbabenko/pre-commit-terraform</a> ;</p></li>
<li><p>our repository contains several Terraform root modules, for all our environments.</p></li>
</ul>
<p>Here is how our directory structure looks like:</p>
<pre><code class="language-plaintext-pika-default">.
├── .git/
├── .gitignore
├── .pre-commit-config.yaml
├── .terraformignore
├── .tflint.d/
│ └── policies/
│ ├── duplicate_acls.rego
│ └── duplicate_acls_test.rego
├── .tflint.hcl
├── envs/
│ ├── staging/
│ │ ├── .terraform/
│ │ ├── .terraform.lock.hcl
│ │ ├── foo.tf
│ │ └── ...
│ ├── preprod/
│ │ ├── .terraform/
│ │ ├── .terraform.lock.hcl
│ │ ├── foo.tf
│ │ └── ...
│ └── prod/
│ ├── .terraform/
│ ├── .terraform.lock.hcl
│ ├── foo.tf
│ └── ...
├── modules/
│ └── custom_acl/
│ ├── .terraform/
│ ├── .terraform.lock.hcl
│ ├── main.tf
│ └── ...
└── README.md</code></pre>
<p>This <code>pre-commit</code> config tells the hook to use the root <code>.tflint.hcl</code> configuration file for every subdirectory, and to delegate the directory change to <code>tflint</code> so the error messages include the whole path of the file and not the path relative to the directory.</p>
<pre class="hljs-highlight"><code class="language-yaml"><span class="hljs-attr">repos:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">repo:</span> <span class="hljs-string">https://github.com/antonbabenko/pre-commit-terraform</span>
<span class="hljs-attr">rev:</span> <span class="hljs-string">v1.99.5</span>
<span class="hljs-attr">hooks:</span>
<span class="hljs-bullet">-</span> <span class="hljs-attr">id:</span> <span class="hljs-string">terraform_tflint</span>
<span class="hljs-attr">args:</span>
<span class="hljs-bullet">-</span> <span class="hljs-string">--args=--config=__GIT_WORKING_DIR__/.tflint.hcl</span>
<span class="hljs-bullet">-</span> <span class="hljs-string">--args=--fix</span>
<span class="hljs-bullet">-</span> <span class="hljs-string">--hook-config=--delegate-chdir</span></code></pre>
<p>Then, we configure <code>tflint</code> using <code>.tflint.hcl</code> placed at the root of the repository:</p>
<pre class="hljs-highlight"><code class="language-plaintext">plugin "opa" {
enabled = true
version = "0.9.0"
source = "github.com/terraform-linters/tflint-ruleset-opa"
# this is relative to the directory in which tflint will be run: each subdir of envs/ and modules/
policy_dir = "../../.tflint.d/policies"
}</code></pre>
<p>As mentioned in the comment, we must declare the <code>policy_dir</code> relative to where <code>tflint</code> runs. Since it changes directory to run two levels deep, and we want our policies to be shared, we have to tell it to look for policies in the root of the repo.</p>
<h2 id="conclusion">
<a href="proxy.php?url=#conclusion" class="anchor" title="Link to this heading"></a>Conclusion</h2>
<p>This was quite a piece, but I’m glad we now have something to catch issues before they even happen. All of this can work locally, on the developer’s machine, before even spending time planning thus reducing the feedback loop to a minimum.</p>
<p>There is still a lot to learn about Rego, which I’m still deeply unfamiliar with but looks like a powerful tool. I don’t know whether I’ll spend much more time with it though, as I don’t have other immediate use cases.</p>
<p>Let me know if you see something I can improve in this setup! I’m still quite new to all this and I’m eager to learn.</p>
</div>
ContextFollowing my Terraform module adventures, I wanted to detect potential duplicates in our numerous ACLs, because they would only produce errors during application of the plan, after merging. And relying...tag:gabnotes.org,2005:Post/641922025-07-27T20:56:44Z2025-11-16T17:30:56ZReduce Terraform plan time when using modules<div class="trix-content">
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--jpg">
<img height="3200" width="4800" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/nnEsbKlGYjHEVQWqBMfp6in1rd4ovPy7q1rywqrNbyY/s:3840:3840/fn:ralph-hutter-xLs4XSQmxtE-unsplash/plain/s3://pika-production/w5j8whwencu8o8ba94emea7bf4le" data-original-src="proxy.php?url=https://cdn.u.pika.page/3qKW2mk8Og619lpigI7FWiGz-PkS2JNkC4g1Up2zzXw/fn:ralph-hutter-xLs4XSQmxtE-unsplash/plain/s3://pika-production/w5j8whwencu8o8ba94emea7bf4le" alt="" src="proxy.php?url=https://cdn.u.pika.page/PmqdsbnrGPhF-s5pVvFRTdhxz3TkyAw3aIdu2p9KhFE/s:1800:1400/fn:ralph-hutter-xLs4XSQmxtE-unsplash/plain/s3://pika-production/w5j8whwencu8o8ba94emea7bf4le">
<figcaption class="attachment__caption" aria-hidden="true">
Photo de <a href="proxy.php?url=https://unsplash.com/fr/@pixelfreund?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Ralph Hutter</a> sur <a href="proxy.php?url=https://unsplash.com/fr/photos/jauge-analogique-noir-et-blanc-xLs4XSQmxtE?utm_content=creditCopyText&utm_medium=referral&utm_source=unsplash">Unsplash</a>
</figcaption>
</figure></div>
<h2 id="context">
<a href="proxy.php?url=#context" class="anchor" title="Link to this heading"></a>Context</h2>
<p>My team at OVHcloud is in the process of adopting Terraform for various tasks related to software deployment on our internal infrastructure. The Developer Platform team offers a Terraform provider, which exposes raw resources that we can manipulate to deploy software, expose APIs, manage access control, etc.</p>
<blockquote><p>I won’t explain all the Terraform related terms in this article. You can refer to the <a href="proxy.php?url=https://developer.hashicorp.com/terraform/docs/glossary">Terraform glossary</a>.</p></blockquote>
<p>After some experimentation, I decided to write a couple of Terraform modules<sup id="fnref:1"><a class="footnote-ref" data-id="78e469ac-205c-4df1-82b6-89f21ad3b564" href="proxy.php?url=#fn:1">1</a></sup> to reduce the boilerplate of our use cases.</p>
<h2 id="issue">
<a href="proxy.php?url=#issue" class="anchor" title="Link to this heading"></a>Issue</h2>
<p>One of the modules was dedicated to producing a <code>gateway_policy</code> resource, and here’s how I initially implemented it:</p>
<pre class="hljs-highlight"><code class="language-plaintext">variable "project" {
type = string
}
variable "stack" {
type = string
}
data "project" "this" {
name = var.project
}
data "kubernetes_tenant" "this" {
project = data.project.this.name
name = var.stack
}
data "gateway_account" "this" {
project = data.project.this.name
name = var.project
}
resource "gateway_policy" "this" {
kubernetes_tenant = data.kubernetes_tenant.this.id
gateway_account = data.gateway_account.this.id
# ...
}</code></pre>
<blockquote><p>I’m omitting what makes it interesting as a module, it’s a bunch of variable manipulation unrelated to what I want to discuss in this article.</p></blockquote>
<p>It was called like this, more than eighty times:</p>
<pre class="hljs-highlight"><code class="language-plaintext">module "acl_foo" {
source = "../../modules/custom_acl"
project = "project_name"
stack = "stack_name"
# ...
}</code></pre>
<p>After adding these module calls to our project, our plan time was roughly 80 seconds (1min20s). The plan contained lots of the following:</p>
<pre class="hljs-highlight"><code class="language-plaintext">module.acl_foo.data.project.this: Reading...
module.acl_foo.data.kubernetes_tenant.this: Reading...
module.acl_foo.data.gateway_account.this: Reading...</code></pre>
<p>Terraform was fetching the same data over and over again, because the variables <code>project</code> and <code>stack</code> were (purposefully) given the same value in every call. Far from a useful use of resources!</p>
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--gif">
<img height="270" width="480" data-zoom-src="proxy.php?url=/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NzUyMDUsInB1ciI6ImJsb2JfaWQifX0=--92c653996362f764f8fbdbc212f73f1ce0aca55d/stamp.gif" data-original-src="proxy.php?url=/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NzUyMDUsInB1ciI6ImJsb2JfaWQifX0=--92c653996362f764f8fbdbc212f73f1ce0aca55d/stamp.gif" alt="GIF showing a man sitting in a cubicle, stamping a piece of paper, in a loop." src="proxy.php?url=/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NzUyMDUsInB1ciI6ImJsb2JfaWQifX0=--92c653996362f764f8fbdbc212f73f1ce0aca55d/stamp.gif">
</figure></div>
<h2 id="refactor">
<a href="proxy.php?url=#refactor" class="anchor" title="Link to this heading"></a>Refactor</h2>
<p>The <code>gateway_policy</code> resource needs access to the <code>kubernetes_tenant</code> and <code>gateway_account</code> though, so I refactored my module to take these as inputs instead.</p>
<pre class="hljs-highlight"><code class="language-plaintext">variable "kubernetes_tenant_id" {
type = string
}
variable "gateway_account_id" {
type = string
}
resource "gateway_policy" "this" {
kubernetes_tenant = var.kubernetes_tenant_id
gateway_account = var.gateway_account_id
# ...
}</code></pre>
<p>The calls now look like this:</p>
<pre class="hljs-highlight"><code class="language-plaintext">module "acl_foo" {
source = "../../modules/custom_acl"
kubernetes_tenant_id = data.kubernetes_tenant.foo.id
gateway_account_id = data.gateway_account.foo.id
# ...
}</code></pre>
<h2 id="learnings">
<a href="proxy.php?url=#learnings" class="anchor" title="Link to this heading"></a>Learnings</h2>
<p>This allowed Terraform to fetch the data only once and pass it down to all module calls. The plan time went down from 80s to 40s, with the same result.</p>
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--gif">
<img height="129" width="230" data-zoom-src="proxy.php?url=/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NzUyOTksInB1ciI6ImJsb2JfaWQifX0=--5d93dbaae38aefc6e056c56b695acb8aca300b16/the%20flash.gif" data-original-src="proxy.php?url=/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NzUyOTksInB1ciI6ImJsb2JfaWQifX0=--5d93dbaae38aefc6e056c56b695acb8aca300b16/the%20flash.gif" alt="The Flash is running" src="proxy.php?url=/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsiZGF0YSI6NzUyOTksInB1ciI6ImJsb2JfaWQifX0=--5d93dbaae38aefc6e056c56b695acb8aca300b16/the%20flash.gif">
</figure></div>
<p>I learned a couple of things along the way:</p>
<ul>
<li><p><code>data</code> calls are costly: push them up, and avoid them in modules if possible</p></li>
<li><p>it’s worth properly reading the whole plan from time to time to look for optimisation opportunities, instead of skipping to the end where the infrastructure changes are listed.</p></li>
</ul>
<ol class="footnotes"><li id="fn:1" data-id="78e469ac-205c-4df1-82b6-89f21ad3b564"><p>You can think of modules as kind of functions that take inputs, produce resources, and return outputs.</p></li></ol>
</div>
ContextMy team at OVHcloud is in the process of adopting Terraform for various tasks related to software deployment on our internal infrastructure. The Developer Platform team offers a Terraform provider,...tag:gabnotes.org,2005:Post/610362025-06-09T11:24:00Z2026-01-05T08:04:57ZHello, Pika!<div class="trix-content">
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--jpg">
<img height="1817" width="2329" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/ViVUkOlsIMF4Ii5fsxNR5QFibKwS0bZ5ZnhH4O4oXDk/s:3840:3840/fn:pika%281%29/plain/s3://pika-production/sccpyhdpoytjbru14tevcr6pmjzn" data-original-src="proxy.php?url=https://cdn.u.pika.page/Rt16Nlb9sE3gAt8EzUVgAXO9lnb7Dc3jVpxl-G6db9E/fn:pika%281%29/plain/s3://pika-production/sccpyhdpoytjbru14tevcr6pmjzn" alt="" src="proxy.php?url=https://cdn.u.pika.page/rdeNR7wCYJTYrm8LhaM6RzLGK_Mzlp7uqVzeM46OXgk/s:1800:1400/fn:pika%281%29/plain/s3://pika-production/sccpyhdpoytjbru14tevcr6pmjzn">
<figcaption class="attachment__caption" aria-hidden="true">
<a href="proxy.php?url=https://commons.wikimedia.org/wiki/File:American_pika_(ochotona_princeps)_with_a_mouthful_of_flowers.jpg">Frédéric Dulude-de Broin</a>, <a href="proxy.php?url=https://creativecommons.org/licenses/by-sa/4.0">CC BY-SA 4.0</a>, via Wikimedia Commons
</figcaption>
</figure></div>
<p>TL;DR: this blog is now hosted at <a href="proxy.php?url=https://pika.page/">Pika</a>! Many thanks to Barry for the smooth migration.</p>
<p>I was lately using a self-hosted Ghost instance to publish on this blog but I was unsatisfied for several reasons:</p>
<ul>
<li><p>The UI was built around features like paid subscriptions, engagement, growth, …</p></li>
<li><p>The editing experience on mobile was suboptimal</p></li>
<li><p>It was hungry for RAM — my poor VPS had trouble handling three instances even with remote databases.</p></li>
</ul>
<p>The few things I liked were that it’s free software and it has built-in first-class citizen email sending capability.</p>
<p>I went on the hunt for another blogging software to migrate my three instances then (this blog and two family travel logs). I wanted something:</p>
<ul>
<li><p>simple,</p></li>
<li><p>easily self-hostable or affordable in SaaS,</p></li>
<li><p>beautiful by default,</p></li>
<li><p>usable by my non-tech wife.</p></li>
</ul>
<p>I found many platforms, some of which great but none filling my criteria. They were either ugly, too geeky, crippled with AI, required building things myself, wouldn’t work out of the box, had an editor that’s hard to work with on mobile, pushed towards monetization, weren’t maintained anymore…</p>
<p>Then, I found <a href="proxy.php?url=https://pika.page">Pika</a>. It’s not free or open-source software but the people at <a href="proxy.php?url=https://goodenough.us/">Good Enough</a> have values that resonate with my own. I found its simplicity refreshing and its pricing fair. It can be customized with color themes and fonts and power users can inject custom CSS. You get 50 blog posts for free and to go further (or to use a custom domain), the subscription is $60/year. They don’t <em>yet</em> have email built-in, but I hear it’s cooking 👨🏻🍳</p>
<blockquote><p>💡 Edit 2026-01-05: Email has landed a few months ago.</p></blockquote>
<blockquote><p>Pika is blogging powered by people. No algorithms or AI, but real human beings writing about their experiences. 🐇</p></blockquote>
<p>I had three blogs to migrate and $180/year for one hobby and two family blogs was not conceivable, so I sent an email to discuss the options. I had the pleasure to exchange with <a href="proxy.php?url=https://bjhess.com/">Barry</a>, and he told me that they had in their backlog a “multi-blog” feature on a single subscription. He was kind enough to send a coupon so that I only had to pay a single subscription and we could merge the accounts when the feature lands.</p>
<p>Barry then worked on an import script for Ghost, which would help me get started here without having to manually copy over my posts. It was not 100% perfect but it was <em>good enough</em> so that I only had an hour or so of work to fix a few things here and there.</p>
<p>Once I’ll have migrated the third and last blog, I’ll be able to shut down the <a href="proxy.php?url=https://gabnotes.org/posts/moving-the-home-server-to-a-vps">MySQL instance</a> that supports it and nearly halve my blog direct costs (from ~95€/year to ~53€/year at the current exchange rates), while supporting people building good enough software.</p>
<blockquote><p>💡 I’ve since migrated all three blogs. For an update on my setup’s pricing, read <a href="proxy.php?url=https://gabnotes.org/posts/cloud-setup-pricing-update">this piece</a>.</p></blockquote>
<p>Thank you very much again Barry, for bearing with my tsunami of questions and helping me migrate. I’m very happy here for now.</p>
<p>I encourage you to try out Pika if you’re looking for a place to host your blog!</p>
</div>
TL;DR: this blog is now hosted at Pika! Many thanks to Barry for the smooth migration. I was lately using a self-hosted Ghost instance to publish on this blog but...tag:gabnotes.org,2005:Post/610342025-06-08T22:09:23Z2025-11-16T17:44:12ZI’m moving to Linux, bye macOS<div class="trix-content">
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--jpg">
<img height="4480" width="6720" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/j-4ppJmRmy3iiWykAjS33GyM1umvmg7e8sMPaUyuouo/s:3840:3840/fn:martin-wettstein-4CVMWrWh3xU-unsplash_1/plain/s3://pika-production/jy6tow61an00bw3mzj40jcma7mls" data-original-src="proxy.php?url=https://cdn.u.pika.page/BJ2juxJmgJuZdVOkzhhti-dk2HBnAA4VrgLUUrop0J4/fn:martin-wettstein-4CVMWrWh3xU-unsplash_1/plain/s3://pika-production/jy6tow61an00bw3mzj40jcma7mls" alt="" src="proxy.php?url=https://cdn.u.pika.page/A2zqAMHl55plMA5oeYNgecGhtOqMNrhcWDmEP_BE5BQ/s:1800:1400/fn:martin-wettstein-4CVMWrWh3xU-unsplash_1/plain/s3://pika-production/jy6tow61an00bw3mzj40jcma7mls">
<figcaption class="attachment__caption" aria-hidden="true">
Photo by <a href="proxy.php?url=https://unsplash.com/fr/@ncx1701d">Martin Wettstein</a>
</figcaption>
</figure></div>
<p>I’ve been a heavy user of Apple products for a while now (see my <a href="proxy.php?url=https://gabnotes.org/posts/my-setup-at-home">two</a> <a href="proxy.php?url=https://gabnotes.org/posts/my-setup-at-home-122021-update">previous</a> posts mentioning it), and it’s time to leave the walled garden for more free and open source pastures.</p>
<p>I first requested a Linux laptop at work. They were kind enough to provide one even though my macOS machine hadn’t reach its “time to live”<sup id="fnref:1"><a class="footnote-ref" data-id="45379af1-4372-4d14-b736-a0c771cd5110" href="proxy.php?url=#fn:1">1</a></sup>. The laptop is setup with Ubuntu 22.04 LTS, and I was feeling already lighter than with the previous machine (despite the heavier hardware 🙃).</p>
<p>Then came the personal machine. I was daily driving a MacBook Air M3, which is compact, light, powerful and energy efficient. I’ve been eyeing on the Framework Laptop 13 for a while now and, <a href="proxy.php?url=https://kevquirk.com/blog/why-i-decided-on-the-framework-13-for-my-next-laptop">inspired by Kev,</a> decided to pre-order the latest generation, DIY edition. You can find my config by following <a href="proxy.php?url=https://frame.work/fr/fr/share-my-laptop?token=883cdaa6eb0">this link</a>. I went with the lowest end AMD CPU, some expansion cards, and purchased RAM and storage separately. It cost me 1380€ total.</p>
<p>I received it this week and the unboxing experience was very pleasant. I installed RAM and storage with ease. It’s not my first computer build, but the instructions were detailed and there were videos to help with every step. I’ve had more trouble than I’d have liked installing the screen bezel though, but it’s there now.</p>
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--jpeg">
<img height="1435" width="1200" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/DeXL7k86zhQyefQRpaIahaAFktTuzdWyoUIT6r45WPs/s:3840:3840/fn:IMG_7253/plain/s3://pika-production/tca32ngkln454d15if8893mr784v" data-original-src="proxy.php?url=https://cdn.u.pika.page/13OcOuLIkOxJhWqgXPHrxDFKArFkwcpie2xQrCBqTIo/fn:IMG_7253/plain/s3://pika-production/tca32ngkln454d15if8893mr784v" alt="On the foreground, a Framework laptop rests on a table. The screen is black. It's open, we can see the internal components. In the background, another computer displays the Framework documentation with video guides." src="proxy.php?url=https://cdn.u.pika.page/I-49uqyoZMqXJJ4oZKfdCb_sk8-gzsv9s9fvzFe-XsA/s:1800:1400/fn:IMG_7253/plain/s3://pika-production/tca32ngkln454d15if8893mr784v">
</figure></div>
<p>The result looks and feels fantastic!</p>
<p>There are a few things that I miss from my MacBook:</p>
<ul>
<li><p>AirDrop and Continuity: I used to be able to instantly share information between my iPhone and my computer. After switching to Fedora this seems gone — if you have workarounds, let me know!</p></li>
<li><p>Fanless design: the fan turns on a bit more than I’d like — which would be never, let’s be honest.</p></li>
<li><p>One hand lid opening: the lid on the Framework 13 is hard to open. The notch on the bottom part is not very deep and the hinge is strong.</p></li>
<li><p>The trackpad: I’ve never met a better trackpad than on Mac laptops. The trackpad on Framework is hard to press on top.</p></li>
</ul>
<p>There are a few things I prefer on Framework:</p>
<ul>
<li><p>It can run Linux</p></li>
<li><p>It has hardware switches for mic and camera</p></li>
<li><p><em>I</em> can repair it</p></li>
<li><p>It can be upgraded</p></li>
</ul>
<p>All in all, I’m quite happy with his move. Installing Fedora was a breeze, everything worked out of the box. I’m not 100% sold on GNOME though, I may try a different desktop environment. I may also cover it with stickers, I’m not sure yet 😇</p>
<ol class="footnotes"><li id="fn:1" data-id="45379af1-4372-4d14-b736-a0c771cd5110"><p>For economical and ecological reasons, the policy at OVHcloud is to keep company provided laptops at least three years and to only replace them on-demand and not automatically.</p></li></ol>
</div>
I’ve been a heavy user of Apple products for a while now (see my two previous posts mentioning it), and it’s time to leave the walled garden for more free...tag:gabnotes.org,2005:Post/605092025-05-25T18:12:03Z2025-11-16T17:31:03ZUse semaphores to limit processing<div class="trix-content">
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--0&q=80&w=2000">
<img height="916" width="2000" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/U5qyYpiTQOu7jn1p2R3IHBGdOLNiWLxORI3_6Sjy-ds/s:3840:3840/fn:photo-1559495673-f6bf69e08495%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDE5fHx0cmFmZmljJTIwbGlnaHR8ZW58MHx8fHwxNzQ4MDk4MTkxfDA%26ixlib%3Drb-4.1/plain/s3://pika-production/dgenoldd0648sxnwv3zsj51egte2" data-original-src="proxy.php?url=https://cdn.u.pika.page/51tSrHPJjJwYt1vOPWhAWc9ctYgfgjKLqWSnol8lYOo/fn:photo-1559495673-f6bf69e08495%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDE5fHx0cmFmZmljJTIwbGlnaHR8ZW58MHx8fHwxNzQ4MDk4MTkxfDA%26ixlib%3Drb-4.1/plain/s3://pika-production/dgenoldd0648sxnwv3zsj51egte2" alt="" src="proxy.php?url=https://cdn.u.pika.page/ySnkU6S00NM9RZ7aspw8pFbJUvJXys7abYdeXSANc4A/s:1800:1400/fn:photo-1559495673-f6bf69e08495%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDE5fHx0cmFmZmljJTIwbGlnaHR8ZW58MHx8fHwxNzQ4MDk4MTkxfDA%26ixlib%3Drb-4.1/plain/s3://pika-production/dgenoldd0648sxnwv3zsj51egte2">
<figcaption class="attachment__caption" aria-hidden="true">
Photo by <a href="proxy.php?url=https://unsplash.com/@noahdominic?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit">Noah Dominic</a> / <a href="proxy.php?url=https://unsplash.com/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit">Unsplash</a>
</figcaption>
</figure></div>
<p>Two months ago, I was busy building a system to automatically upload files sent via our company messaging system to a malware detection API. The system has to respond within 10 seconds, otherwise the file is automatically distributed to recipients. Everything was fine during testing. Just after the rollout however, we started to see our Go microservice fall apart and restart again and again.</p>
<h2 id="-death-by-powerpoint">
<a href="proxy.php?url=#-death-by-powerpoint" class="anchor" title="Link to this heading"></a>📊 Death by Powerpoint</h2>
<p>During testing I only worked with small files because the upload limit on the test messaging instance was set to 5MB and because it was faster. However, on the production system the threshold is much higher: 50MB per file. My poor k8s pods and their 20MB RAM limit were quickly overwhelmed by users sharing large Powerpoint files.</p>
<p>So, first: increase the RAM limit and scale up the deployment to 8 replicas instead of 2. That would give me some time to handle the next issue.</p>
<h2 id="-whats-next">
<a href="proxy.php?url=#-whats-next" class="anchor" title="Link to this heading"></a>🐜 What's next?</h2>
<p>Next was the fact that a <em>single</em> file upload was limited to 50MB but users could upload <em>several</em> files at once in a single message.</p>
<p>One solution could then be to split the file handling in two parts: for each received message, loop through all files and send each to a queue system to be handled by subscribers. But I was happy with my single, no-dependency binary and I didn't want to introduce complexity unless absolutely necessary.</p>
<p>Also, since the limiting factor in this case was the file size and not the number of files, I wanted to find a solution that would work for either one large file or many small files in parallel.</p>
<p>The idea then was to introduce a semaphore.</p>
<h2 id="-whats-that">
<a href="proxy.php?url=#-whats-that" class="anchor" title="Link to this heading"></a>🚦 What's that?</h2>
<p>A semaphore works a bit like a bag of tokens. If you want to access a section restricted by the semaphore, you request some tokens. If there are enough tokens in the bag, you're allowed to continue. Otherwise, you either wait until there are, or you error out (depending on your use case).</p>
<p>In our case, we defined the maximum RAM a pod should have as 200MB and we configured the semaphore size to a bit less than 200x1024x1024x8 (the number of bytes in 200MB). Then, before downloading a file, we send a HEAD request to retrieve the file size in bytes without downloading it. We request that many tokens to the semaphore with a timeout of 5 seconds. If the timeout is reached, we reject the file so it's not distributed. Otherwise, we continue down our happy path.</p>
<h2 id="-how-does-that-look-like">
<a href="proxy.php?url=#-how-does-that-look-like" class="anchor" title="Link to this heading"></a>🧑🏻💻 How does that look like?</h2>
<p>Go has semaphores defined in the "extended" stdlib at <a href="proxy.php?url=https://pkg.go.dev/golang.org/x/sync/semaphore">https://pkg.go.dev/golang.org/x/sync/semaphore</a></p>
<pre class="hljs-highlight"><code class="language-go"><span class="hljs-keyword">package</span> main
<span class="hljs-keyword">import</span> (
<span class="hljs-string">"context"</span>
<span class="hljs-string">"log/slog"</span>
<span class="hljs-string">"time"</span>
<span class="hljs-string">"golang.org/x/sync/semaphore"</span>
)
<span class="hljs-function"><span class="hljs-keyword">func</span> <span class="hljs-title">main</span><span class="hljs-params">()</span></span> {
<span class="hljs-keyword">const</span> maxBytes = <span class="hljs-number">200</span> * <span class="hljs-number">1024</span> * <span class="hljs-number">1024</span> * <span class="hljs-number">8</span>
sem := semaphore.NewWeighted(maxBytes)
ctx, cancel := context.WithTimeout(context.Background(), <span class="hljs-number">5</span>*time.Second)
<span class="hljs-keyword">defer</span> cancel()
fileSize := <span class="hljs-type">int64</span>(<span class="hljs-number">12</span> * <span class="hljs-number">1024</span> * <span class="hljs-number">8</span>) <span class="hljs-comment">// retrieved from HEAD request</span>
<span class="hljs-keyword">if</span> err := sem.Acquire(ctx, fileSize); err != <span class="hljs-literal">nil</span> {
<span class="hljs-comment">// err is ctx.Err()</span>
slog.Error(err.Error())
<span class="hljs-keyword">return</span>
}
<span class="hljs-keyword">defer</span> sem.Release(fileSize)
<span class="hljs-comment">// continue processing</span>
slog.Info(<span class="hljs-string">"done"</span>)
}</code></pre>
<p>Playground: <a href="proxy.php?url=https://go.dev/play/p/rVUK3ftTAPN">https://go.dev/play/p/rVUK3ftTAPN</a></p>
<h2 id="-and-the-results">
<a href="proxy.php?url=#-and-the-results" class="anchor" title="Link to this heading"></a>📈 And the results?</h2>
<p>Not single OOMKill since this update was deployed! Also, only two files in the last 30 days were rejected because of the semaphore timeout out of more than 90k files processed, or 0.002%. Not bad!</p>
</div>
Two months ago, I was busy building a system to automatically upload files sent via our company messaging system to a malware detection API. The system has to respond within...tag:gabnotes.org,2005:Post/605082025-05-21T07:41:18Z2025-11-16T17:31:07ZMoving the home server to a VPS<div class="trix-content">
<div class="attachment-gallery"><figure class="attachment attachment--preview attachment--0&q=80&w=2000">
<img height="1333" width="2000" data-zoom-src="proxy.php?url=https://cdn.u.pika.page/8AxLxFV_4zOB7XSxgO9t6zM_YiTHKEA4Gko_G34elH4/s:3840:3840/fn:photo-1504253163759-c23fccaebb55%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDF8fGNsb3VkfGVufDB8fHx8MTc0NzUzMzMxOXww%26ixlib%3Drb-4.1/plain/s3://pika-production/0jjl3axfy0as0onqlktl749b6gv6" data-original-src="proxy.php?url=https://cdn.u.pika.page/1Wasrywnr10iCnpjR3LPRk2LvLRLxCCJxC80cwPzcgs/fn:photo-1504253163759-c23fccaebb55%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDF8fGNsb3VkfGVufDB8fHx8MTc0NzUzMzMxOXww%26ixlib%3Drb-4.1/plain/s3://pika-production/0jjl3axfy0as0onqlktl749b6gv6" alt="" src="proxy.php?url=https://cdn.u.pika.page/aWTUHZflq60mqn-7hzPJ4hXCCr734oREZo7iaYzTIsU/s:1800:1400/fn:photo-1504253163759-c23fccaebb55%3Fcrop%3Dentropy%26cs%3Dtinysrgb%26fit%3Dmax%26fm%3Djpg%26ixid%3DM3wxMTc3M3wwfDF8c2VhcmNofDF8fGNsb3VkfGVufDB8fHx8MTc0NzUzMzMxOXww%26ixlib%3Drb-4.1/plain/s3://pika-production/0jjl3axfy0as0onqlktl749b6gv6">
<figcaption class="attachment__caption" aria-hidden="true">
Photo by <a href="proxy.php?url=https://unsplash.com/@anikeevxo?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit">Vladimir Anikeev</a> / <a href="proxy.php?url=https://unsplash.com/?utm_source=ghost&utm_medium=referral&utm_campaign=api-credit">Unsplash</a>
</figcaption>
</figure></div>
<h2 id="-context">
<a href="proxy.php?url=#-context" class="anchor" title="Link to this heading"></a>📖 Context</h2>
<p>In 2025, I've decided to reassess the software I self-host and by extension maintain. This led to the decision to move away from Nextcloud, among other things.</p>
<p>Nextcloud is a great piece of software but it took a lot of my mental energy to maintain in a working state, especially for several users who rely on in being up and running when they need it the most. It got better when I moved to the "all-in-one" setup, but still.</p>
<p>I also wanted to move most of my hosted software away from the server in my house because I realized that I don't like waking up to a server reporting that backups could not complete due to faulty disk sectors. Important services would move to a cloud server while storage hungry but non-critical services (like media consumption) could stay home. Also, it would free up valuable space under the TV 😁</p>
<h2 id="-file-storage">
<a href="proxy.php?url=#-file-storage" class="anchor" title="Link to this heading"></a>💾 File storage</h2>
<p>Nextcloud served among other things as backup for our photo library and I didn't want to pay a fortune in server storage, so I took the decision to move my personal "drive" to pCloud, along with my wife's. They have lifetime offers, store data in Europe, and I had already paid for a lifetime personal plan in 2018. I upgraded it to a family plan and I don't worry about storage anymore.</p>
<p>2TB is plenty for now, including the photo library. I'd like to find a solution for the iCloud shared library pictures though, because we have a bunch of pictures being uploaded twice.</p>
<p>I keep it synced to an S3 bucket with <a href="proxy.php?url=https://rclone.org">rclone</a> because I feel safer having a backup plan in case pCloud suddenly goes out of business.</p>
<h2 id="-new-setup">
<a href="proxy.php?url=#-new-setup" class="anchor" title="Link to this heading"></a>☁️ New setup</h2>
<p>I rent a VPS at OVHcloud. It has 2 vCores, 4GB of RAM and 40GB of storage. Quite the downgrade from my 8 cores, 56GB RAM and 2TB SSD server at home but now at least I don't have to manage the hardware myself.</p>
<p>I also discovered the Web Cloud Database offer: for less than 8€ per month, you can order a MySQL, Redis, MariaDB or PostgreSQL host and create as many databases as you want provided that you stay in the 8GB of storage and 512MB of RAM.</p>
<p>Finally, I took the time to setup Tailscale to avoid exposing SSH and some web services to the world and It Just Works™.</p>
<p>The resources are scarcer on the VPS, especially the 2 vCores, but it forces me to be more mindful with what I host. And I can always throw more money at it and upgrade at the click of a button if I really need to.</p>
<h2 id="-speaking-of-money">
<a href="proxy.php?url=#-speaking-of-money" class="anchor" title="Link to this heading"></a>💶 Speaking of money</h2>
<p>The current setup at home has cost about 1500€ over the course of 4.5 years (~334€/year) and the SSDs are starting to show signs of weakness. I already replaced two, I don't want to do more. They're about 90-100€ apiece at current prices.</p>
<p>With the VPS, here's the cost breakdown (including 20% VAT in France):</p>
<ul>
<li><p>VPS: 13.56€/mo</p></li>
<li><p>Automated backup option: 3.96€/mo</p></li>
<li><p>MySQL: 7.908€/mo</p></li>
<li><p>PostgreSQL: 7.908€/mo</p></li>
<li><p><strong>Total</strong>: 33.336€/mo or 400.032€/year</p></li>
</ul>
<p>I could save some money - about 30€/year - by committing to the VPS for one or two years but I'm giving myself some time before doing that.</p>
<p>OVHcloud is my employer but we don't have insider discounts on products so you shouldn't have any surprises if you want to replicate this setup yourself 🙂</p>
<p>The MySQL DB is used only for three instances of Ghost (open source blog software). I'm looking at alternative solutions for the blogs. Removing MySQL would save me ~96€/year so the recurring cost would go even below the homemade setup.</p>
<h2 id="-conclusion">
<a href="proxy.php?url=#-conclusion" class="anchor" title="Link to this heading"></a>📕 Conclusion</h2>
<p>All in all I'm pretty happy with the move. It was mostly painless since I had most of my configuration handled by Ansible so there were few things I had to do manually. I still have some cleanup to do on the home server but I feel lighter already.</p>
<p>Yes it costs more for less resources, but it covers my needs. I can upgrade or move away easily, and it takes away tasks I don't want to handle anymore. I could bring the cost down a bit more with commitment, but for now I'm enjoying the freedom.</p>
<p>I'll probably sell the hardware I have at home and replace it with a mini PC and a single disk for media consumption, it should be more than enough.</p>
</div>
📖 ContextIn 2025, I've decided to reassess the software I self-host and by extension maintain. This led to the decision to move away from Nextcloud, among other things. Nextcloud is...