<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"><title>blog.fidelramos.net - Software</title><link href="https://blog.fidelramos.net/" rel="alternate"/><link href="https://blog.fidelramos.net/feeds/software.atom.xml" rel="self"/><id>https://blog.fidelramos.net/</id><updated>2026-07-04T19:43:29.109105+00:00</updated><entry><title>Vibe Coding Escapades: Venice.ai KDE widget</title><link href="https://blog.fidelramos.net/software/vibe-coding-escapades-venice-kde-widget" rel="alternate"/><published>2026-06-25T00:00:00+00:00</published><updated>2026-06-25T18:41:26.119215+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-06-25:/software/vibe-coding-escapades-venice-kde-widget</id><summary type="html">&lt;h2 id="the-problem"&gt;The Problem&lt;/h2&gt;
&lt;p&gt;I’ve been using Venice.ai (&lt;a href="https://venice.ai/chat?ref=XPrCO2"&gt;referral link if you want to sign up&lt;/a&gt;) for quite some time now for all my artificial intelligence needs.
I love it because, unlike most AI platforms, it’s &lt;strong&gt;private&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When you use Venice’s own models (open-source models running on their …&lt;/li&gt;&lt;/ul&gt;</summary><content type="html">&lt;h2 id="the-problem"&gt;The Problem&lt;/h2&gt;
&lt;p&gt;I’ve been using Venice.ai (&lt;a href="https://venice.ai/chat?ref=XPrCO2"&gt;referral link if you want to sign up&lt;/a&gt;) for quite some time now for all my artificial intelligence needs.
I love it because, unlike most AI platforms, it’s &lt;strong&gt;private&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;When you use Venice’s own models (open-source models running on their own servers), they don’t keep a log of the &lt;em&gt;prompts&lt;/em&gt;, meaning your usage is private. Most providers keep logs of everything, and even worse are those that identify you personally (say, through your Google account) so they can link all your conversations to create profiles with a very dangerous level of detail.&lt;/li&gt;
&lt;li&gt;When using external models (GPT, Claude, Gemini, etc.), Venice acts as an anonymizing proxy, meaning the final model doesn’t know who’s making the request. Obviously, they receive the full &lt;em&gt;prompt&lt;/em&gt; and will log it, but as long as you don’t include personal information in it, they shouldn’t be able to identify you.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Another thing I like is that they have a &lt;a href="https://venice.ai/token"&gt;token&lt;/a&gt; system through which I enjoy a Pro account (with 100 VVV), and I also have credits to spend every day (1 DIEM gives you $1 in credits every day, forever).&lt;/p&gt;
&lt;p&gt;The problem was that sometimes I’d be coding in Opencode and suddenly see a message saying I’d run out of credits.
I wanted a widget on my desktop (&lt;a href="https://kde.org/"&gt;KDE&lt;/a&gt;) so I could see at a glance how much I had left to spend for the day.
In the past, this would have been a project buried in the dustbin of history, but after discovering &lt;a href="https://github.com/Zetaphor/whatcable-linux"&gt;whatcable-linux&lt;/a&gt;, I realized that creating these widgets was well within my reach.&lt;/p&gt;
&lt;p&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/vibe_coding_escapades_3/retrosun.webp"&gt;&lt;figcaption&gt;Venice.ai KDE widget over a retro sun&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 id="implementation"&gt;Implementation&lt;/h2&gt;
&lt;p&gt;I started a new project with Deepseek V4 Pro on Opencode.
As I mentioned in the &lt;a href="https://blog.fidelramos.net/software/vibe-coding-escapades-pelican-copy-code"&gt;previous article&lt;/a&gt; it had just been released and promised results similar to Claude Opus at a fraction of the price, so I wanted to test drive it on real projects.&lt;/p&gt;
&lt;p&gt;The first step was to generate an &lt;a href="https://github.com/haplo/venice-kde-widget/blob/main/AGENTS.md"&gt;&lt;em&gt;AGENTS.md&lt;/em&gt;&lt;/a&gt; file with the project’s objective and structure.
Then I asked it to document the &lt;a href="https://docs.venice.ai/api-reference/endpoint/billing/balance"&gt;Venice API we were going to use&lt;/a&gt;.
So far, so good.&lt;/p&gt;
&lt;p&gt;The next step was to generate a static widget that would display the information I needed, but without interacting with any APIs for the time being.
This is where Deepseek failed miserably: the &lt;a href="https://en.wikipedia.org/wiki/QML"&gt;QML&lt;/a&gt; files (the language specific to the Qt library used by KDE) it generated wouldn’t even compile; the widget wouldn’t load at all.
After several attempts, I had to give up and switch to Claude Opus 4.7.&lt;/p&gt;
&lt;p&gt;Opus discovered that the problem was that &lt;strong&gt;the widget was being cached&lt;/strong&gt; by KDE, so even if I installed a new version, the old one kept loading.
Deepseek had probably fixed the problem, but I was still seeing the old cached version.&lt;/p&gt;
&lt;p&gt;Opus found a way to restart Plasma so that it would load the new version of the widget, and with that, it compiled and displayed correctly.
From there, the process became much more iterative.&lt;/p&gt;
&lt;p&gt;Next step: connect to the actual API.
To simplify things, I asked it to read the &lt;em&gt;token&lt;/em&gt; from an environment variable, and Opus &lt;a href="https://github.com/haplo/venice-kde-widget/commit/e489c9831acf427d432aff5b6c0f722be348786e"&gt;got it right on the first try&lt;/a&gt;.
I could already see &lt;strong&gt;my real data&lt;/strong&gt;!
As a personal project, I could have stopped there, it was already useful and did what I needed, but I wanted to polish and publish it in case others wanted to use it, so I iterated a bit more.&lt;/p&gt;
&lt;p&gt;The next step was to store the token securely, making sure it wasn't stored in plain text anywhere.
I asked Opus to explore some options:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href="https://github.com/KDE/kwallet"&gt;kwallet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://man.archlinux.org/man/core/libsecret/secret-tool.1.en"&gt;secret-tool&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.passwordstore.org/"&gt;pass&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I decided to go with &lt;em&gt;secret-tool&lt;/em&gt; because, although KWallet is KDE’s native system,
it has long been integrated with libsecret (and therefore with secret-tool).
Opus &lt;a href="https://github.com/haplo/venice-kde-widget/commit/7bf3fe4d8c584282afde0b1bb14d615e683db0dc"&gt;once-shotted it&lt;/a&gt; (minus &lt;a href="https://github.com/haplo/venice-kde-widget/commit/c289380250685326ce2f3171f7009ed339ac487d"&gt;a startup bug&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;The rest involved iterating on the user interface (gotta love those transparent widgets), tweaking colors and positions a bit, and adding some configuration options; nothing major worth mentioning.&lt;/p&gt;
&lt;h2 id="result"&gt;Result&lt;/h2&gt;
&lt;p&gt;After spending the weekend tinkering, the result was
&lt;a href="https://github.com/haplo/venice-kde-widget/"&gt;venice-kde-widget&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/vibe_coding_escapades_3/screenshot.webp"&gt;&lt;figcaption&gt;Venice.ai KDE widget&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;I use it every day, and it’s been working like a charm. :)&lt;/p&gt;
&lt;h2 id="lessons"&gt;Lessons&lt;/h2&gt;
&lt;p&gt;The initial problem (that the widget was cached by KDE Plasma) made me realize that many models require more specific instructions than others; they’re less creative.
If I had asked them to consider alternative reasons for why it kept failing, they might have figured it out, but I myself didn’t think of the possibility that it was cached.
Opus was the only one proactive enough to think, “Let’s confirm whether the code that’s actually running is the one we have in the repository.”
It was smarter than me, all things considered.&lt;/p&gt;
&lt;p&gt;That proactivity is something &lt;a href="https://simonw.substack.com/p/claude-fable-is-relentlessly-proactive"&gt;Simon Willison wrote about in relation to Fable 5&lt;/a&gt;.
This experience suggests that this applies to other models as well.
In other words, Claude Opus 4.7 was more proactive than Deepseek V4 Pro.
I’m curious to know if &lt;strong&gt;proactivity can be measured across models&lt;/strong&gt;, and if there are any studies on the subject.&lt;/p&gt;
&lt;p&gt;Another lesson is that for a project using such a niche technology (KDE widgets in QML), most models won’t yield great results, they likely &lt;strong&gt;lack training&lt;/strong&gt;.
Perhaps the results would have been better if I had told them to look at the code of other widgets to use as a &lt;strong&gt;reference&lt;/strong&gt;.
Opus ended up doing just that for some problems he couldn’t solve: he looked at the other widgets installed on the system to analyze their code.
It probably would have been a good idea to &lt;strong&gt;search for similar widgets&lt;/strong&gt; and request an &lt;strong&gt;analysis before implementing&lt;/strong&gt;.
Or even better fine-tune a coding model on KDE widgets, but that would be way out of scope.&lt;/p&gt;
&lt;p&gt;The results from this project, along with those from &lt;a href="https://blog.fidelramos.net/software/vibe-coding-escapades-pelican-copy-code"&gt;pelican-copy-code in the previous article&lt;/a&gt;, have led me to significantly &lt;strong&gt;reduce my use of Deepseek V4 Pro&lt;/strong&gt;; it’s cheaper, but it’s not worth it if it leads me down dead ends with code that doesn’t work and needs to be fixed.&lt;/p&gt;
&lt;p&gt;I continued using &lt;strong&gt;Kimi 2.6&lt;/strong&gt; as my default model, mainly because it’s private on Venice.ai.
That changed with &lt;strong&gt;GLM 5.2&lt;/strong&gt;, but that’s another story.&lt;/p&gt;</content><category term="Software"/><category term="ai"/><category term="kde"/><category term="programming"/></entry><entry><title>Self-hosted Tailscale, Part 4: Tailscale-Only Web Services</title><link href="https://blog.fidelramos.net/software/tailscale-4-web-services" rel="alternate"/><published>2026-06-04T00:00:00+00:00</published><updated>2026-06-04T14:53:17.820687+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-06-04:/software/tailscale-4-web-services</id><summary type="html">&lt;p&gt;I've been running a &lt;a href="https://blog.fidelramos.net/software/homelab"&gt;home server&lt;/a&gt; with a bunch of self-hosted services (Immich, Jellyfin, Nextcloud...) behind a single Caddy instance using &lt;code&gt;caddy-docker-proxy&lt;/code&gt; (documented &lt;a href="https://blog.fidelramos.net/software/switch-nginx-caddy-docker-compose"&gt;in this post&lt;/a&gt;).
Some of them have genuine public uses, e.g. sharing a folder in Nextcloud or an album in Immich, or connecting to Jellyfin through …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I've been running a &lt;a href="https://blog.fidelramos.net/software/homelab"&gt;home server&lt;/a&gt; with a bunch of self-hosted services (Immich, Jellyfin, Nextcloud...) behind a single Caddy instance using &lt;code&gt;caddy-docker-proxy&lt;/code&gt; (documented &lt;a href="https://blog.fidelramos.net/software/switch-nginx-caddy-docker-compose"&gt;in this post&lt;/a&gt;).
Some of them have genuine public uses, e.g. sharing a folder in Nextcloud or an album in Immich, or connecting to Jellyfin through another device (like a Smart TV).
However the overwhelming majority of my interaction with those services is personal, from my own devices, and exposing them to the public internet is a considerable &lt;strong&gt;security risk&lt;/strong&gt;, as they are entry points to my internal systems.&lt;/p&gt;
&lt;p&gt;The solution now that I have &lt;a href="https://blog.fidelramos.net/software/tailscale-1-headscale-and-clients"&gt;Tailscale running&lt;/a&gt; is to make as many services as practicable accessible only through the tailnet.
To achieve this I will run &lt;strong&gt;two Caddy instances in parallel&lt;/strong&gt;: one bound to the public interface, for public-facing services; another bound to the Tailscale interface, for tailnet-only ones.
It wasn't immediately obvious how to do that with &lt;code&gt;caddy-docker-proxy&lt;/code&gt;, but I got it working without dirty hacks.&lt;/p&gt;
&lt;p&gt;This post is the write-up of the setup I landed on, and the pile of gotchas I hit getting there.&lt;/p&gt;
&lt;h2 id="the-architecture"&gt;The architecture&lt;/h2&gt;
&lt;p&gt;Two separate Caddy instances in Docker, both using &lt;code&gt;caddy-docker-proxy&lt;/code&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;caddy-public&lt;/code&gt;&lt;/strong&gt;: bound to the LAN IP, reached from the Internet via the router's port forward on 80/443. Handles public services.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;caddy-tailscale&lt;/code&gt;&lt;/strong&gt;: bound to the host's Tailscale IP (&lt;code&gt;100.x.y.z&lt;/code&gt;), reachable only from devices on my tailnet. Handles private services.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each Caddy watches Docker labels with a different prefix, so services opt in to one or the other (or both, rarely) by using the appropriate label prefix and joining the appropriate Docker network.&lt;/p&gt;
&lt;p&gt;Private services use certificates issued via &lt;a href="https://letsencrypt.org/docs/challenge-types/#dns-01-challenge"&gt;DNS-01 challenge&lt;/a&gt; through Gandi (my DNS hosting provider).
Public services keep using normal certs via HTTP-01.&lt;/p&gt;
&lt;p&gt;Here's the high-level picture:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;Internet ──► router:443 ──► host:LAN_IP:443 ──► caddy-public    ──► headscale
Tailnet  ──► host:TAILSCALE_IP:443          ──► caddy-tailscale ──► immich, jellyfin, nextcloud, etc.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;h2 id="why-two-caddys-and-not-one-with-an-ip-allowlist"&gt;Why two Caddys and not one with an IP allowlist?&lt;/h2&gt;
&lt;p&gt;I considered this.
A single Caddy with a &lt;a href="https://caddyserver.com/docs/modules/http.matchers.remote_ip"&gt;&lt;code&gt;remote_ip&lt;/code&gt;&lt;/a&gt; matcher restricting certain hostnames to &lt;code&gt;100.64.0.0/10&lt;/code&gt; would have worked.
&lt;strong&gt;But&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Port 443 would still be reachable from the internet for those hostnames. I'd be relying on Layer 7 filtering to keep attackers out of services that shouldn't be WAN-exposed at all.&lt;/li&gt;
&lt;li&gt;Private services need DNS-01 certs (because Let's Encrypt can't reach a &lt;code&gt;100.x&lt;/code&gt; address for HTTP-01), public services don't.&lt;/li&gt;
&lt;li&gt;One forgotten Docker label and an internal service is on the public internet.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two separate Caddys gives me network-layer isolation: the private services literally aren't listening on a WAN-reachable socket.
That's a much stronger guarantee than "listening but configured to reject".&lt;/p&gt;
&lt;h2 id="the-docker-compose-setup"&gt;The Docker Compose setup&lt;/h2&gt;
&lt;p&gt;Here's the essential shape (with some env vars for clarity):&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;caddy-public&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;.&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;dockerfile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Dockerfile.caddy&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy-docker-proxy-gandi:local&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy-public&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;unless-stopped&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;CADDY_INGRESS_NETWORKS=caddy_public&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"${LAN_IP:?LAN_IP&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;must&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;be&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;set}:80:80"&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"${LAN_IP:?LAN_IP&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;must&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;be&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;set}:443:443"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy.email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${CADDY_EMAIL}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${CADDY_FILES_PATH}/public:/data&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy_public&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1.1.1.1&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;9.9.9.9&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;caddy-tailscale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;.&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;dockerfile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;Dockerfile.caddy&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy-docker-proxy-gandi:local&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy-tailscale&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;unless-stopped&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;CADDY_INGRESS_NETWORKS=caddy_tailscale&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;CADDY_DOCKER_LABEL_PREFIX=caddy_ts&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;GANDI_BEARER_TOKEN=${GANDI_BEARER_TOKEN}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"${TAILSCALE_IP:?TAILSCALE_IP&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;must&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;be&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;set}:80:80"&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"${TAILSCALE_IP:?TAILSCALE_IP&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;must&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;be&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;set}:443:443"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy_ts.email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${CADDY_EMAIL}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy_ts.acme_dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"gandi&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;{env.GANDI_BEARER_TOKEN}"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${CADDY_FILES_PATH}/tailscale:/data&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy_tailscale&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1.1.1.1&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;9.9.9.9&lt;/span&gt;

&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;caddy_public&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy_public&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;caddy_tailscale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy_tailscale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;A few things worth calling out:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Separate label namespaces.&lt;/strong&gt; &lt;code&gt;CADDY_DOCKER_LABEL_PREFIX=caddy_ts&lt;/code&gt; tells the tailnet Caddy to read labels starting with &lt;code&gt;caddy_ts&lt;/code&gt;. The public Caddy uses the default &lt;code&gt;caddy&lt;/code&gt;. Services opt in by prefix, no possibility of cross-contamination.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;CADDY_INGRESS_NETWORKS&lt;/code&gt;&lt;/strong&gt;: Each Caddy is scoped to discover only containers on its own network. Without this, each Caddy would try to proxy every labeled container it could see via the Docker socket, and they'd fight.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Separate &lt;code&gt;/data&lt;/code&gt; volumes.&lt;/strong&gt; The two Caddys have their own certificates storage. Sharing would work but separate is cleaner and removes an edge case.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Binding to specific IPs, not &lt;code&gt;0.0.0.0&lt;/code&gt;.&lt;/strong&gt; This one cost me an embarrassing amount of debugging time (see below).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The &lt;code&gt;:?&lt;/code&gt; guards on env vars.&lt;/strong&gt; Compose silently falls back to &lt;code&gt;0.0.0.0&lt;/code&gt; if the host-IP part of a port spec is empty. The &lt;code&gt;:?&lt;/code&gt; syntax makes it fail loudly instead. After getting burned by this twice, I will always use this kind of guard when setting up services that must be bound to a specific IP.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="building-a-caddy-image-with-the-gandi-dns-plugin"&gt;Building a Caddy image with the Gandi DNS plugin&lt;/h2&gt;
&lt;p&gt;I use Gandi as my DNS provider, and luckily Caddy has &lt;a href="https://github.com/caddy-dns/gandi"&gt;a plugin for it&lt;/a&gt;.
However the &lt;code&gt;lucaslorentz/caddy-docker-proxy&lt;/code&gt; image doesn't include any DNS plugins, and they have to be compiled in, you can't add them at runtime.
I built a small custom image by creating a &lt;code&gt;Dockerfile.caddy&lt;/code&gt; file:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;CADDY_VERSION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;caddy:${CADDY_VERSION}-builder&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;builder&lt;/span&gt;

&lt;span class="k"&gt;RUN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;xcaddy&lt;span class="w"&gt; &lt;/span&gt;build&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
--with&lt;span class="w"&gt; &lt;/span&gt;github.com/lucaslorentz/caddy-docker-proxy/v2&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
--with&lt;span class="w"&gt; &lt;/span&gt;github.com/caddy-dns/gandi

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;caddy:${CADDY_VERSION}-alpine&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--from&lt;span class="o"&gt;=&lt;/span&gt;builder&lt;span class="w"&gt; &lt;/span&gt;/usr/bin/caddy&lt;span class="w"&gt; &lt;/span&gt;/usr/bin/caddy

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"caddy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"docker-proxy"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Then &lt;code&gt;docker compose build caddy-tailscale&lt;/code&gt; would trigger the image build.&lt;/p&gt;
&lt;p&gt;This pattern is common and &lt;a href="https://hub.docker.com/_/caddy#adding-custom-caddy-modules"&gt;documented to make custom Caddy images&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A key detail is the final &lt;code&gt;CMD&lt;/code&gt;. &lt;code&gt;caddy-docker-proxy&lt;/code&gt; runs in a different mode than the normal Caddy server, and you have to override it explicitly.&lt;/p&gt;
&lt;p&gt;For the Gandi side, I created a Personal Access Token (not a legacy API key) with "Manage domain technical configurations" permission.
I stuffed it into &lt;code&gt;.env&lt;/code&gt; as &lt;code&gt;GANDI_BEARER_TOKEN&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="moving-services-to-the-tailscale-caddy"&gt;Moving services to the tailscale Caddy&lt;/h2&gt;
&lt;p&gt;With DNS-01 working, now it's time to move my web services to the tailnet.&lt;/p&gt;
&lt;p&gt;The changes are easy: put the services in the &lt;code&gt;caddy_tailscale&lt;/code&gt; network, and change the &lt;code&gt;caddy&lt;/code&gt; labels to &lt;code&gt;caddy_ts&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;For example my Immich service (only the relevant parts):&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;immich-server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;caddy_ts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;immich.ts.fidelramos.net&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;caddy_ts.reverse_proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"{{upstreams&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;2283}}"&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy_tailscale&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;immich&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Beautiful, isn't it?&lt;/p&gt;
&lt;h2 id="dns-setup"&gt;DNS setup&lt;/h2&gt;
&lt;p&gt;There are no public DNS records.
Instead I rely on Blocky's &lt;code&gt;customDNS&lt;/code&gt; (see &lt;a href="https://blog.fidelramos.net/software/tailscale-2-ad-blocking-dns"&gt;Part 2&lt;/a&gt;) to create DNS records that will only be visible to my tailnet devices.&lt;/p&gt;
&lt;p&gt;In Blocky's configuration:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;customDNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;customTTL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1h&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;filterUnmappedTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ts.fidelramos.net&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;tail&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# allows 'X.ts.fidelramos.net' as alias for 'X.tail'&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# Friendly names for services on tailnet&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;immich.tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;jellyfin.tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;nextcloud.tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;This requires manually managing the entries and IPs, but this being a humble home network they don't change that often and it's a reasonable compromise.&lt;/p&gt;
&lt;h2 id="verification"&gt;Verification&lt;/h2&gt;
&lt;p&gt;After &lt;code&gt;docker-compose up -d&lt;/code&gt; I check the logs of the containers that everything looks good.
Both caddy instances spin up correctly.
&lt;code&gt;caddy-tailscale&lt;/code&gt; issues new certs through DNS-01 challenge.&lt;/p&gt;
&lt;p&gt;In the host I verify that each caddy listens on their bound IP:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;ss&lt;span class="w"&gt; &lt;/span&gt;-tlnp&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-E&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;':80|:443'&lt;/span&gt;
LISTEN&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4096&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192&lt;/span&gt;.168.1.50:80&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.0.0.0:*
LISTEN&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4096&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192&lt;/span&gt;.168.1.50:443&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.0.0.0:*
LISTEN&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4096&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.1:80&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.0.0.0:*
LISTEN&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;4096&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.1:443&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.0.0.0:*
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;The router only forwards to the LAN IP, so the tailnet listener is invisible to the Internet.&lt;/p&gt;
&lt;p&gt;Public services remain reachable at &lt;code&gt;https://&amp;lt;service&amp;gt;.fidelramos.net&lt;/code&gt; from anywhere.
Private services are reachable at &lt;code&gt;https://immich.ts.fidelramos.net&lt;/code&gt; only from devices on my tailnet.&lt;/p&gt;
&lt;p&gt;New private services need a DNS record in Blocky and a couple labels and a network attachment in Docker compose to be live.&lt;/p&gt;
&lt;h2 id="the-gotchas-i-hit-so-you-wont-have-to"&gt;The gotchas I hit so you won't have to&lt;/h2&gt;
&lt;p&gt;This is the part I wish someone had written down for me, they took significant time to figure out.&lt;/p&gt;
&lt;h3 id="compose-silently-falls-back-to-0000"&gt;Compose silently falls back to &lt;code&gt;0.0.0.0&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;If you write &lt;code&gt;${TAILSCALE_IP}:80:80&lt;/code&gt; and &lt;code&gt;TAILSCALE_IP&lt;/code&gt; is empty, Docker binds &lt;code&gt;0.0.0.0:80&lt;/code&gt;.
Both Caddys then fight for the same port and one fails to start with &lt;code&gt;port is already allocated&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I used &lt;code&gt;${TAILSCALE_IP:?...}&lt;/code&gt; to make it fail loudly if the environment variable is not defined.&lt;/p&gt;
&lt;p&gt;Solving this was quick enough, but then I found the next gotcha...&lt;/p&gt;
&lt;h3 id="failed-containers-leave-zombies"&gt;Failed containers leave zombies&lt;/h3&gt;
&lt;p&gt;When a container fails to bind its ports, Docker leaves it in a weird half-state: &lt;code&gt;NetworkMode&lt;/code&gt; is set correctly, but the container is actually attached to no network.
&lt;code&gt;docker inspect&lt;/code&gt; shows &lt;code&gt;"Networks": {}&lt;/code&gt;, and &lt;code&gt;ip addr&lt;/code&gt; inside the container shows only &lt;code&gt;lo&lt;/code&gt;.
A subsequent &lt;code&gt;docker compose up -d&lt;/code&gt; sees the container "exists" and just starts it without fixing anything.
You end up with a running container that has no connectivity at all.&lt;/p&gt;
&lt;p&gt;The fix was to start with a clean slate, &lt;code&gt;docker compose down&lt;/code&gt;, then &lt;code&gt;docker compose up -d&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="binding-000080-conflicts-with-100xyz80"&gt;Binding &lt;code&gt;0.0.0.0:80&lt;/code&gt; conflicts with &lt;code&gt;100.x.y.z:80&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Even if both IPs are "valid", Docker treats &lt;code&gt;0.0.0.0&lt;/code&gt; as a superset. I had &lt;code&gt;caddy-public&lt;/code&gt; binding &lt;code&gt;0.0.0.0:80&lt;/code&gt; and &lt;code&gt;caddy-tailscale&lt;/code&gt; binding &lt;code&gt;100.64.0.1:80&lt;/code&gt; and got intermittent startup failures depending on order.&lt;/p&gt;
&lt;p&gt;The fix is to pin &lt;strong&gt;both&lt;/strong&gt; to specific IPs (LAN IP for public, Tailscale IP for tailnet).&lt;/p&gt;
&lt;p&gt;No overlap, no race.&lt;/p&gt;
&lt;h3 id="caddy-docker-proxy-needs-the-services-network"&gt;&lt;code&gt;caddy-docker-proxy&lt;/code&gt; needs the service's network&lt;/h3&gt;
&lt;p&gt;The Caddy's &lt;code&gt;CADDY_INGRESS_NETWORKS&lt;/code&gt; filter and the actual routing both require the proxied service to be on the same Docker network as the Caddy.
A service can be on additional networks (for its own database, etc.), but it needs at least the Caddy's ingress network, or it's invisible.&lt;/p&gt;
&lt;h2 id="would-i-do-it-again"&gt;Would I do it again?&lt;/h2&gt;
&lt;p&gt;Yes.
The initial setup was surprisingly painful because there were multiple failures stacking on top of each other, but the resulting architecture is clean and cheap to extend.&lt;/p&gt;
&lt;p&gt;Every new service I add goes through the same dead-simple decision:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Public: &lt;code&gt;caddy.*&lt;/code&gt; labels, &lt;code&gt;caddy_public&lt;/code&gt; network, DNS record in Gandi.&lt;/li&gt;
&lt;li&gt;Private: &lt;code&gt;caddy_ts.*&lt;/code&gt; labels, &lt;code&gt;caddy_tailscale&lt;/code&gt; network, DNS record in Blocky.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No firewall rules to keep in sync; no per-service auth middleware; no "I'll just expose it temporarily" that becomes permanent.&lt;/p&gt;
&lt;p&gt;The short lesson from the whole exercise: Tailscale is excellent at making "just me" services reachable from my devices, but integrating it with a Docker-based reverse proxy has more sharp edges than either technology has on its own.
Binding your listeners to specific IPs, failing loudly on missing env vars, and giving containers explicit DNS resolvers are the three habits that would have saved me most of the pain.&lt;/p&gt;
&lt;h2 id="future-improvement-limited-access-for-other-people"&gt;Future improvement: limited access for other people&lt;/h2&gt;
&lt;p&gt;With this setup I lost the ability to share an Immich album with my family or friends, or to share a Nextcloud folder by sending a link.
These are genuine helpful use cases that I would like to restore in some way.&lt;/p&gt;
&lt;p&gt;For my family I will study getting them access to my tailnet, and then using ACLs to restrict their devices to only the services they need.&lt;/p&gt;
&lt;p&gt;For file sharing I'm researching options like &lt;a href="https://www.filestash.app/"&gt;Filestash&lt;/a&gt; or &lt;a href="https://filebrowserquantum.com/"&gt;FileBrowser Quantum&lt;/a&gt;, which are tailored services for this particular use case.
What I want is to make the full admin UI accessible only through tailnet, and make their public sharing endpoints Internet-routable.&lt;/p&gt;
&lt;p&gt;Whatever solutions I land on I will likely write blog posts about them.&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="howto"/><category term="linux"/><category term="security"/><category term="tailscale"/><category term="vpn"/></entry><entry><title>Vibe Coding Escapades: pelican-copy-code plugin</title><link href="https://blog.fidelramos.net/software/vibe-coding-escapades-pelican-copy-code" rel="alternate"/><published>2026-05-26T00:00:00+00:00</published><updated>2026-05-26T17:00:29.288805+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-05-26:/software/vibe-coding-escapades-pelican-copy-code</id><summary type="html">&lt;h2 id="the-problem"&gt;The Problem&lt;/h2&gt;
&lt;p&gt;I realized that this blog uses a lot of code blocks, but it’s not easy to copy them because there’s no &lt;em&gt;Copy&lt;/em&gt; button on each block.&lt;/p&gt;
&lt;p&gt;I looked for a plugin for &lt;a href="https://getpelican.com/"&gt;Pelican&lt;/a&gt; that would do this, convinced that there must be one already out there …&lt;/p&gt;</summary><content type="html">&lt;h2 id="the-problem"&gt;The Problem&lt;/h2&gt;
&lt;p&gt;I realized that this blog uses a lot of code blocks, but it’s not easy to copy them because there’s no &lt;em&gt;Copy&lt;/em&gt; button on each block.&lt;/p&gt;
&lt;p&gt;I looked for a plugin for &lt;a href="https://getpelican.com/"&gt;Pelican&lt;/a&gt; that would do this, convinced that there must be one already out there, but I was surprised to find nothing!&lt;/p&gt;
&lt;p&gt;It seemed like an ideal project to solve quickly with AI.
I could fix my issue and create a useful project for the community.&lt;/p&gt;
&lt;h2 id="execution"&gt;Execution&lt;/h2&gt;
&lt;p&gt;My goal was to create a new project publishable to PyPI, with these requirements:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Compatible with stable Pelican.&lt;/li&gt;
&lt;li&gt;Installable with &lt;code&gt;pip&lt;/code&gt; and featuring auto-discovery. In other words, it must be a next-generation Pelican plugin; it doesn’t need to be added to the Pelican configuration (&lt;code&gt;pelicanconf.py&lt;/code&gt;), it installs with &lt;code&gt;pip&lt;/code&gt; and runs right away.&lt;/li&gt;
&lt;li&gt;Static JavaScript and CSS files, without &lt;code&gt;npm&lt;/code&gt; or similar.&lt;/li&gt;
&lt;li&gt;No external JavaScript or CSS dependencies, only standard APIs.&lt;/li&gt;
&lt;li&gt;Should not require changes to the templates. Once activated, the plugin automatically inserts the button into all blocks.&lt;/li&gt;
&lt;li&gt;No DOM manipulation; the button is inserted when the Pelican site is generated.&lt;/li&gt;
&lt;li&gt;Configurable.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I used OpenCode (in a sandbox, &lt;a href="https://blog.fidelramos.net/es/software/how-i-sandbox-ai-agents"&gt;I describe it in this other article&lt;/a&gt;), initially with Kimi 2.6, then with Deepseek V4 Pro, and later with Claude Opus 4.7.
I’ll discuss the details in the &lt;em&gt;Lessons&lt;/em&gt; section.&lt;/p&gt;
&lt;p&gt;Development took place in fits and starts over the course of an afternoon.
While I was doing other things, I’d go to the computer, ask it to prepare the next change in planning mode, then come back, quickly review it, and give the go-ahead to implement it.&lt;/p&gt;
&lt;p&gt;I tested the changes locally on this blog.
I simply installed the plugin with &lt;code&gt;uv&lt;/code&gt; in editable mode: &lt;code&gt;uv add --editable ~/Code/pelican-copy-code&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;It’s a tiny project, so I didn’t bother generating or maintaining tests, &lt;em&gt;linting&lt;/em&gt;, or GitHub actions.&lt;/p&gt;
&lt;h2 id="results"&gt;Results&lt;/h2&gt;
&lt;p&gt;The plugin is &lt;code&gt;pelican-copy-code&lt;/code&gt;, published on &lt;a href="https://github.com/haplo/pelican-copy-code"&gt;Github&lt;/a&gt; and &lt;a href="https://pypi.org/project/pelican-copy-code/"&gt;PyPI&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It can be installed directly as intended; it’s just a &lt;code&gt;pip install pelican-copy-code&lt;/code&gt; away.&lt;/p&gt;
&lt;p&gt;Instead of a screenshot, here’s a code block so you can try it out live, just hover over it and click “Copy”:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;swallow_airspeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;laden&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
   &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;laden&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="ne"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"African or European?"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nc"&gt;BlackKnight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
   &lt;span class="k"&gt;def&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="fm"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
       &lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flesh_wounds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"'Tis but a scratch!"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
   &lt;span class="k"&gt;def&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;is_defeated&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
       &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flesh_wounds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;

&lt;span class="nb"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;swallow_airspeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;laden&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"coconut"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;h2 id="lessons"&gt;Lessons&lt;/h2&gt;
&lt;p&gt;At first, I used Kimi 2.6 because it’s my default model on &lt;a href="https://venice.ai/"&gt;Venice.ai&lt;/a&gt;.
It’s cheap, fast, and private (Venice.ai doesn’t keep logs of conversations in its own models).&lt;/p&gt;
&lt;p&gt;I tried switching to Deepseek V4 Pro because it was new and they said it was better for programming, comparable to Claude Opus 4.5.
I didn’t notice much of a difference; for such a small project, either one would probably have worked well.&lt;/p&gt;
&lt;p&gt;It is true that Kimi 2.6 made some mistakes in its initial implementation; for example, some of the configuration settings I asked it to include had no effect, that is, the code never used them.
It was easy to fix in separate sessions, but it opened my eyes to how important the testing phase is.&lt;/p&gt;
&lt;p&gt;I also ended up doing some manual refactoring that I probably could have avoided if I had been more specific in the instructions in &lt;code&gt;AGENTS.md&lt;/code&gt;.
For example, keeping configuration variables in alphabetical order should have been a style requirement in &lt;code&gt;AGENTS.md&lt;/code&gt;.
The agent also placed some configuration variables outside &lt;a href="https://github.com/haplo/pelican-copy-code/blob/f0a625b85caa3ff134ab85bfdf006abad4bd505c/pelican/plugins/copy_code/settings.py#L2"&gt;the dictionary&lt;/a&gt;.
A style error that a human probably wouldn’t have made.&lt;/p&gt;
&lt;p&gt;When it came time to test the plugin for the first time on a real site, it turned out that it wasn't working; it threw an error at build time due to incorrect use of &lt;a href="https://github.com/haplo/pelican-copy-code/blob/f0a625b85caa3ff134ab85bfdf006abad4bd505c/pelican/plugins/copy_code/__init__.py#L8"&gt;signals and generators&lt;/a&gt;.
I asked Deepseek V4 Pro to fix it but it couldn’t figure it out with a couple attempts.
That’s when I switched to Claude Opus 4.7, which found the problem on the first try and fixed it correctly.
I then remembered the advice I’d read here and there about using a smart model as a planner and another cheaper model to implement the changes.
You can tell Opus is the most advanced model, but it’s also much more expensive than Kimi or Deepseek.&lt;/p&gt;
&lt;p&gt;When I implemented &lt;a href="https://github.com/haplo/pelican-copy-code/commit/b76cff671550231135ced60dbd598993a160ebcf"&gt;translation support&lt;/a&gt;, I did it with Deepseek V4 Pro, and it worked well.
However, it duplicated code (the strings “Copy” and “Copied!” were duplicated across two different files).
I asked it to refactor to remove the duplication, but the proposal wasn’t good at all, it eliminated the duplication but added more poorly styled code.
Once again, I switched to Opus 4.7, which proposed a much better solution that actually simplified the code.&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="ai"/><category term="blog"/><category term="pelican"/><category term="programming"/><category term="python"/></entry><entry><title>Vibe Coding Escapades</title><link href="https://blog.fidelramos.net/software/vibe-coding-escapades" rel="alternate"/><published>2026-05-24T00:00:00+00:00</published><updated>2026-05-24T17:54:18.113017+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-05-24:/software/vibe-coding-escapades</id><summary type="html">&lt;p&gt;I'm going to start a new series of articles on how I'm using AI to develop software or solve technical problems.&lt;/p&gt;
&lt;p&gt;The articles will follow this structure:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Problem&lt;/li&gt;
&lt;li&gt;Execution&lt;/li&gt;
&lt;li&gt;Results&lt;/li&gt;
&lt;li&gt;Lessons and findings&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In this article, I don't want to delve into ethical, environmental, or political-economic considerations regarding AI, but …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I'm going to start a new series of articles on how I'm using AI to develop software or solve technical problems.&lt;/p&gt;
&lt;p&gt;The articles will follow this structure:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Problem&lt;/li&gt;
&lt;li&gt;Execution&lt;/li&gt;
&lt;li&gt;Results&lt;/li&gt;
&lt;li&gt;Lessons and findings&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In this article, I don't want to delve into ethical, environmental, or political-economic considerations regarding AI, but rather into my &lt;strong&gt;experience as a developer&lt;/strong&gt;.
Opinions and studies on the impact of AI on software developers' productivity vary widely.
Some say it can multiply productivity, others that while it may seem to improve it, it actually decreases it.
After these months of personal experience, I'm inclined to think that they are &lt;a href="https://www.joshwcomeau.com/email/wham-launch-005-elephant-2-p/"&gt;especially effective for those with deep technical knowledge&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I got my first computer at age 5 (almost 40 years ago now!), which is when I started learning computer science and programming.
I've been using GNU/Linux since 1998.
I completed my higher education in Software Engineering.
I've worked as a software engineer for 15 years.
And with AI, I feel &lt;strong&gt;10 times more productive&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Note that I say “I feel”, because I don't have objective metrics to say just how much more productive I actually am.
Studies suggest that &lt;a href="https://www.rudikershaw.com/articles/how-much-faster"&gt;the improvement is around 20%&lt;/a&gt;, but statistics are usually based on large projects and companies with complex workflows, which isn't the case for me.
When working on new or small projects, I think AIs are very efficient.
In my case, there's no doubt that these projects wouldn't be a reality if I didn't have the help of AIs.&lt;/p&gt;
&lt;p&gt;My main problem in recent years has been time.&lt;br/&gt;
Time to learn something new.&lt;br/&gt;
Time to devote hours to a single task.&lt;br/&gt;
There are too many things to do, and I'm too busy with life to do it all.
I think this is a common experience, especially among parents.&lt;/p&gt;
&lt;p&gt;That's why my ideas would sit for years as &lt;em&gt;TODO&lt;/em&gt; items in my notes.
Now I'm getting into a routine where I glance at that list, pick one item, and successfully complete it in minutes or hours, in spare moments over the weekend.
&lt;strong&gt;This is what I plan to document in this series of articles&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Having said all that, I don't want to ignore &lt;a href="https://tomwojcik.com/posts/2026-02-15/finding-the-right-amount-of-ai/"&gt;the voices&lt;/a&gt; warning of the &lt;a href="https://www.media. mit.edu/publications/your-brain-on-chatgpt/"&gt;negative effects&lt;/a&gt; of the use (or &lt;a href="https://www.reddit.com/r/vibecoding/comments/1qz8opi/does_anyone_get_burnout_from_vibecoding_ive_been/"&gt;abuse&lt;/a&gt;) of AI.
Personally, I hope it doesn't affect me negatively, because I use it relatively little, but I'll try to be introspective about my mental well-being.&lt;/p&gt;
&lt;p&gt;I also read about many programmers who hate AI, to the point of quitting their careers, because what they enjoy is writing the code themselves.
I feel their pain when they have jobs where they're forced to use AI and are pressured to produce more and faster.
Their day-to-day has changed rapidly, and whereas they used to spend most of their time writing code, now that's been reversed, and they have to focus on generating, reviewing, correcting, and fine-tuning, which is much less fun and mentally stressful and exhausting.&lt;/p&gt;
&lt;p&gt;In my case, writing the code is secondary; I care more about the end result than the process.
That's why I'm happy to take the shortcuts that AI offers me, as long as I can maintain control and get a faster result with the quality I'm looking for.&lt;/p&gt;</content><category term="Software"/><category term="ai"/><category term="programming"/></entry><entry><title>Self-hosted Tailscale, Part 3: Syncthing over Tailscale</title><link href="https://blog.fidelramos.net/software/tailscale-3-syncthing" rel="alternate"/><published>2026-05-18T00:00:00+00:00</published><updated>2026-05-18T22:43:27.267049+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-05-18:/software/tailscale-3-syncthing</id><summary type="html">&lt;p&gt;I've been using &lt;a href="https://syncthing.net/"&gt;Syncthing&lt;/a&gt; for years to keep files in sync between my laptop, desktop, phone, tablet and home server. It is an amazing piece of software and has been very reliable, with only the occasional conflict, but I have never lost any data. On the contrary, Syncthing file versioning …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I've been using &lt;a href="https://syncthing.net/"&gt;Syncthing&lt;/a&gt; for years to keep files in sync between my laptop, desktop, phone, tablet and home server. It is an amazing piece of software and has been very reliable, with only the occasional conflict, but I have never lost any data. On the contrary, Syncthing file versioning has saved me more than once.&lt;/p&gt;
&lt;p&gt;Syncthing has its own &lt;a href="https://docs.syncthing.net/specs/globaldisco-v3.html"&gt;discovery&lt;/a&gt; and &lt;a href="https://docs.syncthing.net/specs/relay-v1.html"&gt;NAT traversal&lt;/a&gt; solutions, so devices that are behind firewalls can see each other and still connect.
However that depends on external servers to handle the connections, and they could keep metadata.
Even if they don't, they might degrade performance.&lt;/p&gt;
&lt;p&gt;Now that I have a &lt;a href="https://blog.fidelramos.net/software/tailscale-1-headscale-and-clients"&gt;tailnet interconnecting all my devices&lt;/a&gt;, there's no reason to deal with any of that.
Every peer already has a stable address reachable from every other peer.
Syncthing can just use it, it only needs some configuration.&lt;/p&gt;
&lt;p&gt;This post is about reconfiguring Syncthing to rely on the tailnet exclusively, turning off all the WAN-oriented machinery it no longer needs.&lt;/p&gt;
&lt;div class="toc"&gt;&lt;span class="toctitle"&gt;Table of Contents&lt;/span&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#goals"&gt;Goals&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#why-do-this"&gt;Why do this&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#reconfiguring-syncthing"&gt;Reconfiguring Syncthing&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#about-listen-addresses"&gt;About Listen Addresses&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#configure-peers-by-tailnet-address"&gt;Configure peers by tailnet address&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#firewall"&gt;Firewall&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#router"&gt;Router&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#verifying"&gt;Verifying&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#encountered-pitfalls"&gt;Encountered pitfalls&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#listen-address-tailscale0-syntax"&gt;Listen address %tailscale0 syntax&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#peers-cant-find-each-other-after-disabling-discovery"&gt;Peers can't find each other after disabling discovery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#magicdns-fails-at-startup-on-a-laggy-client"&gt;MagicDNS fails at startup on a laggy client&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#syncthing-on-a-containerized-deployment"&gt;Syncthing on a containerized deployment&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#discovery-announcement-caching"&gt;Discovery announcement caching&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#whats-next"&gt;What's next&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id="goals"&gt;Goals&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Syncthing peers talk to each other &lt;strong&gt;only&lt;/strong&gt; over the tailnet.&lt;/li&gt;
&lt;li&gt;No reliance on Syncthing's public global discovery servers.&lt;/li&gt;
&lt;li&gt;No reliance on Syncthing's relay network.&lt;/li&gt;
&lt;li&gt;No need for NAT traversal, it's handled by tailscale.&lt;/li&gt;
&lt;li&gt;No open ports in the router, no need for UPnP.&lt;/li&gt;
&lt;li&gt;Everything Just Works™ when I'm on the go.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="why-do-this"&gt;Why do this&lt;/h2&gt;
&lt;p&gt;A few small wins that add up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Privacy.&lt;/strong&gt; Syncthing's global discovery phones home to announce &lt;code&gt;(device ID → public IP)&lt;/code&gt;. With the tailnet, that broadcast is pointless and can be disabled.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reliability.&lt;/strong&gt; Relays are slow and occasionally flaky. Direct tailnet connections are fast and consistent, and Tailscale's DERP already provides relay fallback at the network layer if a direct path can't be established.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Simplicity.&lt;/strong&gt; Each peer's address becomes a fixed tailnet IP (or MagicDNS name). No more wondering why two devices aren't finding each other.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security.&lt;/strong&gt; Syncthing listens on TCP/UDP 22000 and UDP 21027 by default. If those ports are exposed on the WAN, they're an exposed service to the Internet. Turning everything off except the tailnet path closes that door, reducing the attack surface.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="reconfiguring-syncthing"&gt;Reconfiguring Syncthing&lt;/h2&gt;
&lt;p&gt;Syncthing ships with a bunch of features to make it work across the internet with no configuration:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Default&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;th&gt;On tailnet&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sync Protocol Listen Addresses&lt;/td&gt;
&lt;td&gt;&lt;code&gt;default&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Where to accept incoming syncs&lt;/td&gt;
&lt;td&gt;Device's tailnet IP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enable NAT traversal&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;td&gt;UPnP / NAT-PMP to open ports in router&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Off&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global Discovery&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;td&gt;Announce device ID → IP on public discovery servers&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Off&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Discovery&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;td&gt;LAN multicast to find peers&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Off&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enable Relaying&lt;/td&gt;
&lt;td&gt;On&lt;/td&gt;
&lt;td&gt;Fallback traffic via public relay servers&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Off&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Global and local discovery, NAT traversal, and relaying are all redundant once every peer has a stable tailnet address.&lt;/p&gt;
&lt;p&gt;I changed the settings on every computer via the Syncthing web UI (&lt;strong&gt;Actions → Settings → Connections&lt;/strong&gt;):&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/tailscale_part_3/syncthing_settings_web.png" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/tailscale_part_3/syncthing_settings_web.png" title="Click for full screen"/&gt;&lt;figcaption&gt;Syncthing web settings&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;And in Android devices in the settings of the Syncthing app (inside &lt;strong&gt;Syncthing Options&lt;/strong&gt;):&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/tailscale_part_3/syncthing_settings_android.png" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/tailscale_part_3/syncthing_settings_android.png" title="Click for full screen"/&gt;&lt;figcaption&gt;Syncthing Android settings&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;You might need to restart Syncthing after saving.&lt;/p&gt;
&lt;h3 id="about-listen-addresses"&gt;About Listen Addresses&lt;/h3&gt;
&lt;p&gt;Sadly Syncthing doesn't support binding to a specific interface, i.e. this won't work:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;tcp://%tailscale0:22000, quic://%tailscale0:22000
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Binding to the tailnet IP directly does work:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;tcp://100.64.0.1:22000, quic://100.64.0.1:22000
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;The listener is then tied to a specific IP that could potentially change if the device ever re-registers, but that will be unlikely enough for me that it's a reasonable compromise.&lt;/p&gt;
&lt;p&gt;An arguably less secure alternative is to leave the listen as &lt;code&gt;default&lt;/code&gt;, which will bind to all interfaces, and then rely on the device firewall to block connections not through the tailnet.&lt;/p&gt;
&lt;h3 id="configure-peers-by-tailnet-address"&gt;Configure peers by tailnet address&lt;/h3&gt;
&lt;p&gt;With global discovery off, peers can no longer find each other automatically.
The solution is to update each device's configuration to point explicitly at the peer's tailnet address:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Device → Edit → Addresses:&lt;/strong&gt;&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;tcp://homeserver.ts.fidelramos.net:22000
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;MagicDNS hostnames work fine here because Syncthing resolves the address on every connection attempt, not just at startup (unlike listen addresses).
If MagicDNS is flaky on a particular client, you can use the raw tailnet IP (&lt;code&gt;tcp://100.64.0.1:22000&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;I do this for every pair of devices in both directions.&lt;/p&gt;
&lt;h3 id="firewall"&gt;Firewall&lt;/h3&gt;
&lt;p&gt;Since Syncthing should only be reachable over the tailnet, I remove existing rules that allowed Syncthing connections on my public network.&lt;/p&gt;
&lt;p&gt;In my case I use firewalld, I removed rules from the &lt;code&gt;public&lt;/code&gt; and &lt;code&gt;home&lt;/code&gt; zones and let &lt;code&gt;trusted&lt;/code&gt; (which is associated to the &lt;code&gt;tailscale0&lt;/code&gt; interface) accept everything automatically:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# Clean up any previously-allowed Syncthing ports&lt;/span&gt;
$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;firewall-cmd&lt;span class="w"&gt; &lt;/span&gt;--permanent&lt;span class="w"&gt; &lt;/span&gt;--zone&lt;span class="o"&gt;=&lt;/span&gt;public&lt;span class="w"&gt; &lt;/span&gt;--remove-port&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;22000&lt;/span&gt;/tcp
$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;firewall-cmd&lt;span class="w"&gt; &lt;/span&gt;--permanent&lt;span class="w"&gt; &lt;/span&gt;--zone&lt;span class="o"&gt;=&lt;/span&gt;public&lt;span class="w"&gt; &lt;/span&gt;--remove-port&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;22000&lt;/span&gt;/udp
$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;firewall-cmd&lt;span class="w"&gt; &lt;/span&gt;--permanent&lt;span class="w"&gt; &lt;/span&gt;--zone&lt;span class="o"&gt;=&lt;/span&gt;public&lt;span class="w"&gt; &lt;/span&gt;--remove-port&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;21027&lt;/span&gt;/udp
$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;firewall-cmd&lt;span class="w"&gt; &lt;/span&gt;--permanent&lt;span class="w"&gt; &lt;/span&gt;--zone&lt;span class="o"&gt;=&lt;/span&gt;home&lt;span class="w"&gt; &lt;/span&gt;--remove-port&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;22000&lt;/span&gt;/tcp
$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;firewall-cmd&lt;span class="w"&gt; &lt;/span&gt;--permanent&lt;span class="w"&gt; &lt;/span&gt;--zone&lt;span class="o"&gt;=&lt;/span&gt;home&lt;span class="w"&gt; &lt;/span&gt;--remove-port&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;22000&lt;/span&gt;/udp
$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;firewall-cmd&lt;span class="w"&gt; &lt;/span&gt;--permanent&lt;span class="w"&gt; &lt;/span&gt;--zone&lt;span class="o"&gt;=&lt;/span&gt;home&lt;span class="w"&gt; &lt;/span&gt;--remove-port&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;21027&lt;/span&gt;/udp
$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;firewall-cmd&lt;span class="w"&gt; &lt;/span&gt;--reload
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Check:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;firewall-cmd&lt;span class="w"&gt; &lt;/span&gt;--zone&lt;span class="o"&gt;=&lt;/span&gt;public&lt;span class="w"&gt; &lt;/span&gt;--list-all
sudo&lt;span class="w"&gt; &lt;/span&gt;firewall-cmd&lt;span class="w"&gt; &lt;/span&gt;--zone&lt;span class="o"&gt;=&lt;/span&gt;home&lt;span class="w"&gt; &lt;/span&gt;--list-all
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Neither zone should list port 22000 anywhere.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;trusted&lt;/code&gt; zone doesn't need anything listed because it's permissive by default.&lt;/p&gt;
&lt;p&gt;For Docker-based Syncthing deployments, it's worth mentioning that a configuration like &lt;code&gt;ports: ["22000:22000"]&lt;/code&gt; bypasses firewalld.
Either bind to the tailnet IP (&lt;code&gt;ports: ["100.64.0.1:22000:22000/tcp"]&lt;/code&gt;) or use host networking.
Native installs are simpler here.&lt;/p&gt;
&lt;h3 id="router"&gt;Router&lt;/h3&gt;
&lt;p&gt;I used to have port TCP/UDP 22000 forwarded to my home server, and other ports for other devices in my home network.
Not anymore:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Remove port 22000 (TCP and UDP) from the router's port-forwarding rules.&lt;/li&gt;
&lt;li&gt;Remove any inbound allow rules for it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Fewer exposed port on the WAN is always a good thing.&lt;/p&gt;
&lt;h2 id="verifying"&gt;Verifying&lt;/h2&gt;
&lt;p&gt;The Syncthing web UI is my first check. On each device:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The peer shows as &lt;strong&gt;Connected&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The connection detail shows something like &lt;code&gt;tcp://100.64.0.2:22000&lt;/code&gt;, i.e. a tailnet address, not a public one.&lt;/li&gt;
&lt;li&gt;No "relay" indicator.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;From the command line on the home server:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;ss&lt;span class="w"&gt; &lt;/span&gt;-tulpn&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-E&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'22000|21027'&lt;/span&gt;
udp&lt;span class="w"&gt;   &lt;/span&gt;UNCONN&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.4:22000&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.0.0.0:*&lt;span class="w"&gt;    &lt;/span&gt;users:&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="s2"&gt;"syncthing"&lt;/span&gt;,pid&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1526&lt;/span&gt;,fd&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;113&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
tcp&lt;span class="w"&gt;   &lt;/span&gt;LISTEN&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="m"&gt;4096&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.4:22000&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.0.0.0:*&lt;span class="w"&gt;    &lt;/span&gt;users:&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="s2"&gt;"syncthing"&lt;/span&gt;,pid&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;1526&lt;/span&gt;,fd&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;110&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Syncthing is still listening on 22000, but on the tailnet IP.&lt;/p&gt;
&lt;p&gt;That's fine, firewalld ensures only the tailnet can reach it.&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# Connection to public IP fails&lt;/span&gt;
$&lt;span class="w"&gt; &lt;/span&gt;nc&lt;span class="w"&gt; &lt;/span&gt;-zv&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;83&lt;/span&gt;.241.182.82&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;22000&lt;/span&gt;
nc:&lt;span class="w"&gt; &lt;/span&gt;connect&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;83&lt;/span&gt;.241.182.82&lt;span class="w"&gt; &lt;/span&gt;port&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;22000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;tcp&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;failed:&lt;span class="w"&gt; &lt;/span&gt;Connection&lt;span class="w"&gt; &lt;/span&gt;refused

&lt;span class="c1"&gt;# Connection to LAN IP fails&lt;/span&gt;
$&lt;span class="w"&gt; &lt;/span&gt;nc&lt;span class="w"&gt; &lt;/span&gt;-zv&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;192&lt;/span&gt;.168.1.10&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;22000&lt;/span&gt;
nc:&lt;span class="w"&gt; &lt;/span&gt;connect&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;83&lt;/span&gt;.241.182.82&lt;span class="w"&gt; &lt;/span&gt;port&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;22000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;tcp&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;failed:&lt;span class="w"&gt; &lt;/span&gt;Connection&lt;span class="w"&gt; &lt;/span&gt;refused

&lt;span class="c1"&gt;# Connection to tailnet IP works&lt;/span&gt;
$&lt;span class="w"&gt; &lt;/span&gt;nc&lt;span class="w"&gt; &lt;/span&gt;-zv&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.1&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;22000&lt;/span&gt;
Connection&lt;span class="w"&gt; &lt;/span&gt;to&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.1&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;22000&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;port&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;tcp/snapenetio&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;succeeded!
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;With Tailscale back on, the same check via tailnet address should succeed.&lt;/p&gt;
&lt;h2 id="encountered-pitfalls"&gt;Encountered pitfalls&lt;/h2&gt;
&lt;h3 id="listen-address-tailscale0-syntax"&gt;Listen address &lt;code&gt;%tailscale0&lt;/code&gt; syntax&lt;/h3&gt;
&lt;p&gt;As mentioned above, don't try to bind Syncthing to an interface name.
Use the device's tailnet IP, or leave as &lt;code&gt;default&lt;/code&gt; (but configure firewall properly).&lt;/p&gt;
&lt;h3 id="peers-cant-find-each-other-after-disabling-discovery"&gt;Peers can't find each other after disabling discovery&lt;/h3&gt;
&lt;p&gt;Obvious in hindsight, but it caught me once: if a peer's address is still set to &lt;code&gt;dynamic&lt;/code&gt; with all discovery off, it has no way to find anyone. Set explicit addresses for every peer.&lt;/p&gt;
&lt;h3 id="magicdns-fails-at-startup-on-a-laggy-client"&gt;MagicDNS fails at startup on a laggy client&lt;/h3&gt;
&lt;p&gt;If Syncthing tries to reach e.g. &lt;code&gt;homeserver.ts.fidelramos.net&lt;/code&gt; before Tailscale is up on a device (e.g. on a phone that was rebooted), the connection fails with a DNS error. It retries a few seconds later and succeeds.
There is nothing to fix, it's just noise in the logs.&lt;/p&gt;
&lt;p&gt;Using IP addresses instead of MagicDNS names avoids the retry but loses the readability and resilience if a device ever reregisters on Headscale.&lt;/p&gt;
&lt;h3 id="syncthing-on-a-containerized-deployment"&gt;Syncthing on a containerized deployment&lt;/h3&gt;
&lt;p&gt;If you run Syncthing in Docker, the &lt;code&gt;ports:&lt;/code&gt; section in your compose file needs to not expose 22000 to the WAN. My recommendation: run it on the host instead, or use &lt;code&gt;network_mode: host&lt;/code&gt;, or bind explicitly to the tailnet IP. Docker's port publishing defaults to &lt;code&gt;0.0.0.0&lt;/code&gt; and bypasses firewalld.&lt;/p&gt;
&lt;h3 id="discovery-announcement-caching"&gt;Discovery announcement caching&lt;/h3&gt;
&lt;p&gt;After I turned off global discovery, other Syncthing users who had previously discovered my device via the public discovery server would still have the stale public IP cached for a while.
This isn't a leak, it's just that they'd try connecting to the old IP and fail.
Purely cosmetic, and it goes away on its own within a day or so. Nothing to do unless you're sharing folders with people outside your tailnet (and in that case, you shouldn't have disabled global discovery in the first place).&lt;/p&gt;
&lt;h2 id="whats-next"&gt;What's next&lt;/h2&gt;
&lt;p&gt;Syncthing now runs entirely inside the tailnet, with no WAN exposure and no reliance on Syncthing's public infrastructure.
I have noticed that devices connect faster, but I haven't run any benchmarks on transfer speeds.&lt;/p&gt;
&lt;p&gt;In Part 4 I'll do the same thing for my web services: I will move most of my apps from a public &lt;code&gt;app.fidelramos.net&lt;/code&gt; domain to a tailnet-only &lt;code&gt;app.ts.fidelramos.net&lt;/code&gt;, using a split &lt;code&gt;caddy-docker-proxy&lt;/code&gt; solution and TLS will still Just Work™.&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="howto"/><category term="linux"/><category term="security"/><category term="syncthing"/><category term="tailscale"/><category term="vpn"/></entry><entry><title>How I Sandbox my AI Agents</title><link href="https://blog.fidelramos.net/software/how-i-sandbox-ai-agents" rel="alternate"/><published>2026-05-14T00:00:00+00:00</published><updated>2026-05-25T13:17:07.448793+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-05-14:/software/how-i-sandbox-ai-agents</id><summary type="html">&lt;p&gt;I've been using &lt;a href="https://opencode.ai/"&gt;Opencode&lt;/a&gt; as my AI coding agent.
Very unlike me, I don't have a long list of reasons why I picked it among the many alternatives.
I just wanted to get started with an agent system that checked my main requirements:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Must be fully open-source.&lt;/li&gt;
&lt;li&gt;Popular, meaning it's …&lt;/li&gt;&lt;/ul&gt;</summary><content type="html">&lt;p&gt;I've been using &lt;a href="https://opencode.ai/"&gt;Opencode&lt;/a&gt; as my AI coding agent.
Very unlike me, I don't have a long list of reasons why I picked it among the many alternatives.
I just wanted to get started with an agent system that checked my main requirements:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Must be fully open-source.&lt;/li&gt;
&lt;li&gt;Popular, meaning it's probably going to last. I don't want to spend time learning to use a program only for it to disappear in a few months.&lt;/li&gt;
&lt;li&gt;Extensible and configurable.&lt;/li&gt;
&lt;li&gt;Support for many AI providers, including my current choice, Venice.ai (here's a &lt;a href="https://venice.ai/chat?ref=XPrCO2"&gt;referral link&lt;/a&gt; for those interested!)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One of the things I don't like about Opencode is that it doesn't use sandboxing, and that's not an oversight but a conscious design decision.
Yes, Opencode does ask before an agent tries access a file outside the project, but the Opencode software itself is not restricted and has system access.&lt;/p&gt;
&lt;p&gt;My solution is simple: I wrap Opencode with &lt;a href="https://github.com/netblue30/firejail"&gt;Firejail&lt;/a&gt;, which I'm already using for most other software in my computers.
This way I restrict Opencode not to have access to what it's not meant to (like my SSH keys!) at the &lt;em&gt;kernel&lt;/em&gt; level.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/how_i_sandbox_my_ai_agents/opencode_ssh_keys.webp" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/how_i_sandbox_my_ai_agents/opencode_ssh_keys.webp" title="Click for full screen"/&gt;&lt;figcaption&gt;Sandboxed Opencode cannot access my SSH keys&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="firejail-setup"&gt;Firejail setup&lt;/h2&gt;
&lt;p&gt;Firejail &lt;a href="https://github.com/netblue30/firejail/pull/7135"&gt;merged an Opencode profile&lt;/a&gt; but is not in a stable release as of the time of this writing.
For now I use my own firejail profile in &lt;em&gt;~/.config/firejail/opencode.profile&lt;/em&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# Firejail profile for opencode&lt;/span&gt;
&lt;span class="c1"&gt;# Description: An open source AI coding agent.&lt;/span&gt;
&lt;span class="c1"&gt;# This file is overwritten after every install/update&lt;/span&gt;
&lt;span class="n"&gt;quiet&lt;/span&gt;
&lt;span class="c1"&gt;# Persistent local customizations&lt;/span&gt;
&lt;span class="n"&gt;include&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;opencode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;
&lt;span class="c1"&gt;# Persistent global definitions&lt;/span&gt;
&lt;span class="n"&gt;include&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;globals&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;

&lt;span class="c1"&gt;# allow executables in HOME, see disable-exec.inc&lt;/span&gt;
&lt;span class="n"&gt;ignore&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;noexec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# blacklisted by disable-programs.inc&lt;/span&gt;
&lt;span class="n"&gt;noblacklist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/.&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;opencode&lt;/span&gt;
&lt;span class="n"&gt;noblacklist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;opencode&lt;/span&gt;
&lt;span class="n"&gt;noblacklist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/.&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;share&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;opencode&lt;/span&gt;

&lt;span class="c1"&gt;# Allows files commonly used by IDEs&lt;/span&gt;
&lt;span class="n"&gt;include&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;allow&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;common&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;devel&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inc&lt;/span&gt;

&lt;span class="c1"&gt;# Disable Wayland&lt;/span&gt;
&lt;span class="n"&gt;blacklist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RUNUSER&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;wayland&lt;/span&gt;&lt;span class="o"&gt;-*&lt;/span&gt;
&lt;span class="c1"&gt;# Disable RUNUSER (cli only; supersedes Disable Wayland)&lt;/span&gt;
&lt;span class="n"&gt;blacklist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;RUNUSER&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# Remove the next blacklist if your system has no /usr/libexec dir,&lt;/span&gt;
&lt;span class="c1"&gt;# otherwise try to add it.&lt;/span&gt;
&lt;span class="n"&gt;blacklist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;usr&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;libexec&lt;/span&gt;

&lt;span class="c1"&gt;# disable-*.inc includes&lt;/span&gt;
&lt;span class="n"&gt;include&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;disable&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;proc&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inc&lt;/span&gt;
&lt;span class="n"&gt;include&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;disable&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;write&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;mnt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inc&lt;/span&gt;
&lt;span class="n"&gt;include&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;disable&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;x11&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inc&lt;/span&gt;
&lt;span class="n"&gt;include&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;disable&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;xdg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inc&lt;/span&gt;

&lt;span class="n"&gt;mkdir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/.&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;opencode&lt;/span&gt;
&lt;span class="n"&gt;whitelist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/.&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;opencode&lt;/span&gt;
&lt;span class="n"&gt;mkdir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;opencode&lt;/span&gt;
&lt;span class="n"&gt;whitelist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/.&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;opencode&lt;/span&gt;
&lt;span class="n"&gt;mkdir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/.&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;share&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;opencode&lt;/span&gt;
&lt;span class="n"&gt;whitelist&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;HOME&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/.&lt;/span&gt;&lt;span class="n"&gt;local&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;share&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;opencode&lt;/span&gt;

&lt;span class="c1"&gt;# Commands that reduce access to resources.&lt;/span&gt;
&lt;span class="n"&gt;caps&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drop&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;all&lt;/span&gt;
&lt;span class="c1"&gt;##caps.keep CAPS&lt;/span&gt;
&lt;span class="c1"&gt;##hostname NAME&lt;/span&gt;
&lt;span class="c1"&gt;# CLI only&lt;/span&gt;
&lt;span class="n"&gt;ipc&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;namespace&lt;/span&gt;
&lt;span class="n"&gt;machine&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="n"&gt;no3d&lt;/span&gt;
&lt;span class="n"&gt;nodvd&lt;/span&gt;
&lt;span class="n"&gt;nogroups&lt;/span&gt;
&lt;span class="n"&gt;noinput&lt;/span&gt;
&lt;span class="n"&gt;nonewprivs&lt;/span&gt;
&lt;span class="n"&gt;noprinters&lt;/span&gt;
&lt;span class="n"&gt;noroot&lt;/span&gt;
&lt;span class="n"&gt;nosound&lt;/span&gt;
&lt;span class="n"&gt;notv&lt;/span&gt;
&lt;span class="n"&gt;nou2f&lt;/span&gt;
&lt;span class="n"&gt;novideo&lt;/span&gt;
&lt;span class="n"&gt;protocol&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;unix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;inet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;inet6&lt;/span&gt;
&lt;span class="n"&gt;seccomp&lt;/span&gt;
&lt;span class="c1"&gt;#tracelog&lt;/span&gt;

&lt;span class="n"&gt;disable&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;mnt&lt;/span&gt;
&lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;
&lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;dev&lt;/span&gt;
&lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;etc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;alternatives&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;ca&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;certificates&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;crypto&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;policies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;dconf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;fonts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;ld&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;so&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;ld&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;so&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;preload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;machine&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;pki&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;resolv&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="n"&gt;ssl&lt;/span&gt;
&lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;tmp&lt;/span&gt;

&lt;span class="n"&gt;dbus&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;none&lt;/span&gt;
&lt;span class="n"&gt;dbus&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;system&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;none&lt;/span&gt;

&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NO_BROWSER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;restrict&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;namespaces&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;This profile uses a &lt;code&gt;whitelist&lt;/code&gt; model, so Opencode will only have access to those specific directories and not even see the rest.
That means when invoking Opencode I need to whitelist the project directory I want it to work on.
I do that like this:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;~/Code/someproject$&lt;span class="w"&gt; &lt;/span&gt;firejail&lt;span class="w"&gt; &lt;/span&gt;--profile&lt;span class="o"&gt;=&lt;/span&gt;opencode&lt;span class="w"&gt; &lt;/span&gt;--whitelist&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="nb"&gt;pwd&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;/usr/bin/opencode
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Because writing all that would quickly become a bore, I have &lt;code&gt;opencode&lt;/code&gt; set up as an &lt;code&gt;abbr&lt;/code&gt;, so it will expand to the above.&lt;/p&gt;
&lt;p&gt;An &lt;code&gt;abbr&lt;/code&gt; is a &lt;a href="https://fishshell.com/docs/current/cmds/abbr.html"&gt;fish shell construct&lt;/a&gt; that works like an alias, but it's editable upon expansion.
This is very useful in case I want to edit the command, like in this case I might need to whitelist some extra path.&lt;/p&gt;
&lt;h2 id="results-and-next-steps"&gt;Results and Next steps&lt;/h2&gt;
&lt;p&gt;I have been using this setup for a month now, and it's been solid once I nailed down the configuration for the Firejail profile.
I can now use coding agents with the assurance that they won't exfiltrate some private data, or that a bug in Opencode could affect my files or my system.&lt;/p&gt;
&lt;p&gt;Next I want to extend this setup so I can use Opencode with a local LLM model through llama.cpp, but without Internet access.
I want this to work on highly personal projects where I wouldn't trust even Venice.ai's private models, like &lt;a href="https://blog.fidelramos.net/software/homelab"&gt;my homelab&lt;/a&gt; configuration.&lt;/p&gt;
&lt;p&gt;I have also developed my own research agents on Opencode, to track long-lived research projects as a set of Markdown files that serve as memory.
It uses an extension of the previous Firejail profile, plus extra Opencode configuration and prompts for the researcher agent and searcher subagents.
I keep refining it but it's already proving useful, so I will probably end up writing a post about it.&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="ai"/><category term="firejail"/><category term="linux"/><category term="security"/></entry><entry><title>Self-hosted Tailscale, Part 2: Ad-blocking DNS</title><link href="https://blog.fidelramos.net/software/tailscale-2-ad-blocking-dns" rel="alternate"/><published>2026-05-11T00:00:00+00:00</published><updated>2026-05-11T18:33:18.531857+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-05-11:/software/tailscale-2-ad-blocking-dns</id><summary type="html">&lt;p&gt;In &lt;a href="https://blog.fidelramos.net/software/tailscale-1-headscale-and-clients"&gt;Part 1&lt;/a&gt; I set up Headscale and had every tailnet client use Cloudflare DNS.
That works, but it's leaving value on the table: every device that connects to my Tailscale gets whatever DNS servers I set, so I might as well run my own DNS server inside the private …&lt;/p&gt;</summary><content type="html">&lt;p&gt;In &lt;a href="https://blog.fidelramos.net/software/tailscale-1-headscale-and-clients"&gt;Part 1&lt;/a&gt; I set up Headscale and had every tailnet client use Cloudflare DNS.
That works, but it's leaving value on the table: every device that connects to my Tailscale gets whatever DNS servers I set, so I might as well run my own DNS server inside the private network and get ad-blocking, tracker-blocking, and internal name resolution everywhere I go.&lt;/p&gt;
&lt;p&gt;This post swaps Cloudflare for &lt;a href="https://0xerr0r.github.io/blocky/"&gt;Blocky&lt;/a&gt;, a lightweight DNS proxy with built-in blocklist support.&lt;/p&gt;
&lt;p&gt;I chose Blocky over &lt;a href="https://pi-hole.net/"&gt;Pi-hole&lt;/a&gt; or &lt;a href="https://github.com/AdguardTeam/Adguardhome"&gt;AdGuard Home&lt;/a&gt; because it's simpler, configured in a single YAML file.
It doesn't try to be a full web product, just a DNS server that does its job and stays out of the way.&lt;/p&gt;
&lt;p&gt;You could easily replace Blocky with some other DNS server or proxy of your choice by keeping the Headscale and Tailscale bits.&lt;/p&gt;
&lt;div class="toc"&gt;&lt;span class="toctitle"&gt;Table of Contents&lt;/span&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#goals"&gt;Goals&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#why-not-just-androids-private-dns"&gt;Why not just Android's Private DNS?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#architecture"&gt;Architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#blocky-in-docker-compose"&gt;Blocky in Docker Compose&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#blocky-configuration"&gt;Blocky configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#sanity-check-from-the-host"&gt;Sanity check from the host:&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#changes-to-headscale"&gt;Changes to Headscale&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#why-this-is-arguably-better-than-device-level-dot"&gt;Why this is arguably better than device-level DoT&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#encountered-pitfalls"&gt;Encountered pitfalls&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#android-cannot-connect-with-private-dns-set"&gt;Android "cannot connect" with Private DNS set&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#dns-resolution-breaks-on-the-host-itself"&gt;DNS resolution breaks on the host itself&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#clients-dont-pick-up-the-new-dns"&gt;Clients don't pick up the new DNS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#have-to-disable-magicdns"&gt;Have to disable MagicDNS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#whats-next"&gt;What's next&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id="goals"&gt;Goals&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Block ads and trackers at the DNS level on every tailnet device.&lt;/li&gt;
&lt;li&gt;Keep DNS encrypted on the wire (DoT upstream), even though traffic between my devices and Blocky is already protected by WireGuard.&lt;/li&gt;
&lt;li&gt;Have internal name resolution, so for example &lt;code&gt;homeserver.ts.fidelramos.net&lt;/code&gt; still resolves.&lt;/li&gt;
&lt;li&gt;No device-level DNS configuration. Everything is pushed by Headscale and clients pick it up automatically.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="why-not-just-androids-private-dns"&gt;Why not just Android's Private DNS?&lt;/h2&gt;
&lt;p&gt;Android has a built-in "Private DNS" feature that does &lt;a href="https://en.wikipedia.org/wiki/DNS_over_TLS"&gt;DoT&lt;/a&gt; to any public resolver.
I was previously using a private AdGuard DNS's server (&lt;code&gt;xxxxxxxx.d.adguard-dns.com&lt;/code&gt;).
I tried using it alongside Tailscale, but it doesn't work: as soon as Tailscale pushes any DNS settings to the client, Android routes DNS through the tunnel via Tailscale's internal resolver (&lt;code&gt;100.100.100.100&lt;/code&gt;), which doesn't speak DoT.
Android's Private DNS probe fails with "cannot connect" and DNS resolution breaks device-wide.&lt;/p&gt;
&lt;p&gt;You can disable "Use Tailscale DNS" in the app to get Private DNS back, but then you lose MagicDNS.&lt;/p&gt;
&lt;p&gt;Forced to choose, I went the other way: run my own DNS on the tailnet and let Headscale push it to all clients.&lt;/p&gt;
&lt;h2 id="architecture"&gt;Architecture&lt;/h2&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;  Device on tailnet
        │
        │ plain DNS (over WireGuard tunnel)
        ▼
  Blocky  ──▶  blocklists (local)
        │
        │ DNS-over-TLS (DoT) to upstream
        ▼
  Cloudflare / Quad9 / Google DNS / etc.
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;Clients talk plain DNS to Blocky's tailnet IP. "Plain" is fine because the whole path is inside a WireGuard tunnel.&lt;/li&gt;
&lt;li&gt;Blocky does the filtering against blocklists it maintains locally.&lt;/li&gt;
&lt;li&gt;For anything that isn't blocked, Blocky forwards upstream over DoT.&lt;/li&gt;
&lt;li&gt;Blocky also keeps mappings for local tailnet domains to tailnet IPs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="blocky-in-docker-compose"&gt;Blocky in Docker Compose&lt;/h2&gt;
&lt;p&gt;I run Blocky in my existing Docker Compose project that runs everything else in my home server.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;compose.yml&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;blocky&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;spx01/blocky:latest&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;blocky&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;network_mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;host&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;unless-stopped&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${BLOCKY_ROOT}/config.yml:/app/config.yml:ro&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/log/blocky:/logs:rw&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;TZ=${TZ}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;healthcheck&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"CMD"&lt;/span&gt;&lt;span class="p p-Indicator"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"/app/blocky"&lt;/span&gt;&lt;span class="p p-Indicator"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"healthcheck"&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1m&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;10s&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;retries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Highlights:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;network_mode: host.&lt;/strong&gt; This is a bit lazy on my part, but opening the Docker port only to the tailnet IP is trickier than one would expect. Blocky will be configured in the next section to only bind to the tailnet IP, so I don't think it's a big security risk. I'm trusting Blocky not to bind to other interfaces or ports in the system.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Config file is read-only&lt;/strong&gt; inside the container. It's safer, and reminds me that the source of truth is my versioned config on disk.&lt;/li&gt;
&lt;li&gt;I had to &lt;code&gt;chown 100 /var/log/blocky&lt;/code&gt;, as that is the UID that the blocky container uses. Otherwise I'd be getting permission errors in Blocky's Docker logs.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="blocky-configuration"&gt;Blocky configuration&lt;/h2&gt;
&lt;p&gt;This is my configuration with internal names redacted. Check out the &lt;a href="https://0xerr0r.github.io/blocky/latest/"&gt;Blocky docs&lt;/a&gt; for full details.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/var/opt/blocky/config/config.yml&lt;/code&gt;&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# =================================================================&lt;/span&gt;
&lt;span class="c1"&gt;# Blocky config — Tailscale/headscale global resolver&lt;/span&gt;
&lt;span class="c1"&gt;# =================================================================&lt;/span&gt;

&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;# Upstream resolvers (encrypted via DoT)&lt;/span&gt;
&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="nt"&gt;upstreams&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;init&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;blocking&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;tcp-tls:dns.quad9.net:853&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;tcp-tls:one.one.one.one:853&lt;/span&gt;

&lt;span class="c1"&gt;# Explicit IPs for upstream resolution at startup&lt;/span&gt;
&lt;span class="c1"&gt;# (avoids chicken-and-egg when Blocky is itself the system resolver)&lt;/span&gt;
&lt;span class="nt"&gt;bootstrapDns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;tcp-tls:1.1.1.1:853&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1.1.1.1&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1.0.0.1&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;upstream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;tcp-tls:9.9.9.9:853&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ips&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;9.9.9.9&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;149.112.112.112&lt;/span&gt;

&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;# Client identification (static, since no DHCP on tailnet)&lt;/span&gt;
&lt;span class="c1"&gt;# Add a new entry here whenever you add a tailscale node&lt;/span&gt;
&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="nt"&gt;clientLookup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;homeserver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;laptop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.2&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.3&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;desktop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.4&lt;/span&gt;

&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;# Custom local DNS records for your tailnet nodes&lt;/span&gt;
&lt;span class="c1"&gt;# Add a new entry here whenever you add a tailscale node&lt;/span&gt;
&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="nt"&gt;customDNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;customTTL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1h&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;filterUnmappedTypes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;rewrite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ts.fidelramos.net&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;tail&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;# allows 'X.ts.fidelramos.net' as shortcut for 'X.tail'&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;mapping&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;homeserver.tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;laptop.tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.2&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;phone.tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.3&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;desktop.tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.4&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# Friendly names for services on tailnet (for Part 4)&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;immich.tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;jellyfin.tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;nextcloud.tail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1&lt;/span&gt;

&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;# Blocking&lt;/span&gt;
&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="nt"&gt;blocking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;denylists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://adaway.org/hosts.txt&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://v.firebog.net/hosts/AdguardDNS.txt&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://v.firebog.net/hosts/Easyprivacy.txt&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;malware&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://urlhaus.abuse.ch/downloads/hostfile/&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://v.firebog.net/hosts/Prigent-Malware.txt&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;tracking&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://v.firebog.net/hosts/Easyprivacy.txt&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;smart-tv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://perflyst.github.io/PiHoleBlocklist/SmartTV.txt&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;kids&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://blocklistproject.github.io/Lists/porn.txt&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://blocklistproject.github.io/Lists/gambling.txt&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;allowlists&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;|&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="no"&gt;# Common false positives&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="no"&gt;clients4.google.com&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="no"&gt;clients2.google.com&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="no"&gt;googleads.g.doubleclick.net&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;clientGroupsBlock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;ads&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;malware&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;tracking&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# Per-client overrides by name (from clientLookup above)&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# phone:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;#   - ads&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;#   - malware&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;#   - tracking&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;#   - smart-tv&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# kids-tablet:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;#   - ads&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;#   - malware&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;#   - kids&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;refreshPeriod&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;24h&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;downloads&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;60s&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;3&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;cooldown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;10s&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;blocking&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;maxErrorsPerSource&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;5&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;blockType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;zeroIp&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;blockTTL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1m&lt;/span&gt;

&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;# Caching&lt;/span&gt;
&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="nt"&gt;caching&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;minTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;5m&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;maxTime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;30m&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;maxItemsCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;0&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;prefetching&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;prefetchExpires&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;2h&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;prefetchThreshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;5&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;cacheTimeNegative&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;30m&lt;/span&gt;

&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;# Ports &amp;amp; binding&lt;/span&gt;
&lt;span class="c1"&gt;# Bind ONLY to tailnet IP so Blocky isn't exposed on LAN/WAN&lt;/span&gt;
&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="nt"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1:53&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;tls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1:853&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="c1"&gt;# optional DoT for tailnet clients&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;http&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;127.0.0.1:4000&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;# metrics / API (local only)&lt;/span&gt;

&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;# Logging &amp;amp; metrics&lt;/span&gt;
&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="nt"&gt;log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;info&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;text&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;privacy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;false&lt;/span&gt;

&lt;span class="nt"&gt;queryLog&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;csv-client&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/logs&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;logRetentionDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;7&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;creationAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;3&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;creationCooldown&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;2s&lt;/span&gt;

&lt;span class="nt"&gt;prometheus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;enable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;false&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/metrics&lt;/span&gt;

&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;# Misc&lt;/span&gt;
&lt;span class="c1"&gt;# ---------------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;# Block leaks of private-use TLDs to public resolvers&lt;/span&gt;
&lt;span class="nt"&gt;specialUseDomains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;rfc6762-appendixG&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;At a high level:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Upstreams over DoT&lt;/strong&gt;: Cloudflare and Quad9 as parallel resolvers, using DNS-over-TLS so the traffic coming from Blocky is encrypted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Custom DNS entries&lt;/strong&gt;: a few internal hostnames I want to resolve tailnet-wide without touching Headscale's &lt;code&gt;extra_records&lt;/code&gt; (I'll cover that in Part 4).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blocking&lt;/strong&gt;: a mix of general lists and a couple of targeted blocklists I care about. I will revisit these lists, but they seemed like a good starting point.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caching&lt;/strong&gt;: enabled, with sensible defaults.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Prometheus metrics&lt;/strong&gt;: off for now, I don't have a metrics collector right now, will do that in my &lt;a href="https://blog.fidelramos.net/software/self-hosted-home-1"&gt;new home server&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Start it up:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;compose&lt;span class="w"&gt; &lt;/span&gt;up&lt;span class="w"&gt; &lt;/span&gt;-d
$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;logs&lt;span class="w"&gt; &lt;/span&gt;-f&lt;span class="w"&gt; &lt;/span&gt;blocky
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;h2 id="sanity-check-from-the-host"&gt;Sanity check from the host:&lt;/h2&gt;
&lt;p&gt;DNS resolves:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;dig&lt;span class="w"&gt; &lt;/span&gt;@100.64.0.1&lt;span class="w"&gt; &lt;/span&gt;example.com
&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;lt;&amp;lt;&amp;gt;&amp;gt;&lt;span class="w"&gt; &lt;/span&gt;DiG&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;9&lt;/span&gt;.20.22&lt;span class="w"&gt; &lt;/span&gt;&amp;lt;&amp;lt;&amp;gt;&amp;gt;&lt;span class="w"&gt; &lt;/span&gt;@100.64.0.1&lt;span class="w"&gt; &lt;/span&gt;example.com
&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;server&lt;span class="w"&gt; &lt;/span&gt;found&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;global&lt;span class="w"&gt; &lt;/span&gt;options:&lt;span class="w"&gt; &lt;/span&gt;+cmd
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Got&lt;span class="w"&gt; &lt;/span&gt;answer:
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-&amp;gt;&amp;gt;HEADER&lt;span class="s"&gt;&amp;lt;&amp;lt;- opco&lt;/span&gt;de:&lt;span class="w"&gt; &lt;/span&gt;QUERY,&lt;span class="w"&gt; &lt;/span&gt;status:&lt;span class="w"&gt; &lt;/span&gt;NOERROR,&lt;span class="w"&gt; &lt;/span&gt;id:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;57184&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;flags:&lt;span class="w"&gt; &lt;/span&gt;qr&lt;span class="w"&gt; &lt;/span&gt;rd&lt;span class="w"&gt; &lt;/span&gt;ra&lt;span class="w"&gt; &lt;/span&gt;ad&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;QUERY:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;,&lt;span class="w"&gt; &lt;/span&gt;ANSWER:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;,&lt;span class="w"&gt; &lt;/span&gt;AUTHORITY:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;,&lt;span class="w"&gt; &lt;/span&gt;ADDITIONAL:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;

&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;OPT&lt;span class="w"&gt; &lt;/span&gt;PSEUDOSECTION:
&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;EDNS:&lt;span class="w"&gt; &lt;/span&gt;version:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;,&lt;span class="w"&gt; &lt;/span&gt;flags:&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;udp:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;512&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;QUESTION&lt;span class="w"&gt; &lt;/span&gt;SECTION:
&lt;span class="p"&gt;;&lt;/span&gt;example.com.&lt;span class="w"&gt;                   &lt;/span&gt;IN&lt;span class="w"&gt;      &lt;/span&gt;A

&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ANSWER&lt;span class="w"&gt; &lt;/span&gt;SECTION:
example.com.&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="m"&gt;300&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;IN&lt;span class="w"&gt;      &lt;/span&gt;A&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="m"&gt;172&lt;/span&gt;.66.147.243
example.com.&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="m"&gt;300&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;IN&lt;span class="w"&gt;      &lt;/span&gt;A&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="m"&gt;104&lt;/span&gt;.20.23.154

&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Query&lt;span class="w"&gt; &lt;/span&gt;time:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;24&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;msec
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;SERVER:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.1#53&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.1&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;UDP&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;WHEN:&lt;span class="w"&gt; &lt;/span&gt;Sun&lt;span class="w"&gt; &lt;/span&gt;May&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;16&lt;/span&gt;:30:57&lt;span class="w"&gt; &lt;/span&gt;WEST&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2026&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;MSG&lt;span class="w"&gt; &lt;/span&gt;SIZE&lt;span class="w"&gt;  &lt;/span&gt;rcvd:&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;72&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Blocked domain (returns 0.0.0.0):&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;dig&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;@100.64.0.1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;doubleclick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt;
&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DiG&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;9.20.22&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;@100.64.0.1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;doubleclick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt;
&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;found&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;global&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Got&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;answer&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;HEADER&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&amp;lt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;opcode&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;QUERY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NOERROR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;id&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;45469&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;qr&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ra&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;QUERY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ANSWER&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTHORITY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ADDITIONAL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;QUESTION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SECTION&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="n"&gt;doubleclick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="n"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;

&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ANSWER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SECTION&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
&lt;span class="n"&gt;doubleclick&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="n"&gt;IN&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="n"&gt;A&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="mf"&gt;0.0.0.0&lt;/span&gt;

&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;msec&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SERVER&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;100.64.0.1&lt;/span&gt;&lt;span class="err"&gt;#&lt;/span&gt;&lt;span class="mi"&gt;53&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;100.64.0.1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;UDP&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;WHEN&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Sun&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;May&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;WEST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2026&lt;/span&gt;
&lt;span class="p"&gt;;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;MSG&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SIZE&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="n"&gt;rcvd&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;49&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Blocky only binds to tailnet IP, no DNS port exposed to the Internet:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;ss&lt;span class="w"&gt; &lt;/span&gt;-ulnp&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;:53
UNCONN&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.1:53&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.0.0.0:*&lt;span class="w"&gt;    &lt;/span&gt;users:&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="s2"&gt;"blocky"&lt;/span&gt;,pid&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;2984612&lt;/span&gt;,fd&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;

$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;ss&lt;span class="w"&gt; &lt;/span&gt;-tlnp&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-E&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;':53|:853'&lt;/span&gt;
LISTEN&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="m"&gt;4096&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.1:53&lt;span class="w"&gt;         &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.0.0.0:*&lt;span class="w"&gt;    &lt;/span&gt;users:&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="s2"&gt;"blocky"&lt;/span&gt;,pid&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;2984612&lt;/span&gt;,fd&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;16&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
LISTEN&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="m"&gt;4096&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="m"&gt;100&lt;/span&gt;.64.0.1:853&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;.0.0.0:*&lt;span class="w"&gt;    &lt;/span&gt;users:&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="s2"&gt;"blocky"&lt;/span&gt;,pid&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;2984612&lt;/span&gt;,fd&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;14&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;h2 id="changes-to-headscale"&gt;Changes to Headscale&lt;/h2&gt;
&lt;p&gt;Now for the Tailscale magic: I tell Headscale to push Blocky's tailnet IP as the global resolver to every client.
Edit &lt;code&gt;/var/opt/headscale/config/config.yaml&lt;/code&gt;, its &lt;code&gt;dns&lt;/code&gt; block changes as follows:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;magic_dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;false&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;override_local_dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;&lt;span class="c1"&gt;# push our DNS to clients, replacing their system DNS&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;base_domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;ts.fidelramos.net&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;nameservers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;global&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.1&lt;/span&gt;&lt;span class="w"&gt;                  &lt;/span&gt;&lt;span class="c1"&gt;# Blocky on the tailnet&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;search_domains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Restart Headscale:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;compose&lt;span class="w"&gt; &lt;/span&gt;restart&lt;span class="w"&gt; &lt;/span&gt;headscale
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Each client has to pull the new settings:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Linux:&lt;/strong&gt; &lt;code&gt;sudo tailscale up --login-server=https://headscale.fidelramos.net --accept-dns=true&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Android:&lt;/strong&gt; toggle the Tailscale VPN switch off and back on. Also &lt;strong&gt;disable Android's Private DNS&lt;/strong&gt; (Settings → Network → Private DNS → Off), since it would conflict.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Verify from a Linux client:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;$&lt;span class="w"&gt; &lt;/span&gt;resolvectl&lt;span class="w"&gt; &lt;/span&gt;status&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;grep&lt;span class="w"&gt; &lt;/span&gt;-A2&lt;span class="w"&gt; &lt;/span&gt;tailscale0
&lt;span class="c1"&gt;# Should show 100.64.0.1 as the DNS server on tailscale0&lt;/span&gt;

$&lt;span class="w"&gt; &lt;/span&gt;dig&lt;span class="w"&gt; &lt;/span&gt;example.com
&lt;span class="c1"&gt;# Should resolve via Blocky&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;h2 id="why-this-is-arguably-better-than-device-level-dot"&gt;Why this is arguably better than device-level DoT&lt;/h2&gt;
&lt;p&gt;The encryption story ends up equivalent or stronger:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Device → Blocky&lt;/strong&gt;: plain DNS, but inside a WireGuard tunnel. Your ISP sees a UDP stream to your home server, but nothing else.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Blocky → upstream DNS&lt;/strong&gt;: encrypted DNS-over-TLS. Your upstream sees the query from your host server, but not which device made it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And I get things I couldn't get with per-device DoT:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Autoconfiguration&lt;/strong&gt;, just by connecting through Tailscale.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Centralized blocklists&lt;/strong&gt;. Change one file, every device benefits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Per-client query logs&lt;/strong&gt; in one place. Blocky can log queries by client IP.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Internal DNS entries&lt;/strong&gt;, I'll use them in Part 4.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Works identically on every platform&lt;/strong&gt;, no Android Private DNS dance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No need to change router configuration&lt;/strong&gt;. My current ISP router doesn't allow me to override the DNS, so this is a neat way of setting DNS on all my devices.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="encountered-pitfalls"&gt;Encountered pitfalls&lt;/h2&gt;
&lt;h3 id="android-cannot-connect-with-private-dns-set"&gt;Android "cannot connect" with Private DNS set&lt;/h3&gt;
&lt;p&gt;Already mentioned in Part 1, but worth repeating because it bites everyone: with Tailscale's DNS active, Android's Private DNS setting must be &lt;strong&gt;Off&lt;/strong&gt; or &lt;strong&gt;Automatic&lt;/strong&gt;.
Setting it to a strict DoT hostname breaks DNS entirely on the device while Tailscale is connected.&lt;/p&gt;
&lt;h3 id="dns-resolution-breaks-on-the-host-itself"&gt;DNS resolution breaks on the host itself&lt;/h3&gt;
&lt;p&gt;If your home server uses &lt;code&gt;127.0.0.53&lt;/code&gt; (systemd-resolved) and you then ask it to use Blocky for everything, and Blocky is in a container that depends on DNS to start... you can paint yourself into a corner.&lt;/p&gt;
&lt;p&gt;Two ways out:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Leave the host's &lt;code&gt;/etc/resolv.conf&lt;/code&gt; pointing at an external resolver (e.g. Cloudflare) and only push Blocky to &lt;em&gt;tailnet clients&lt;/em&gt;. This is what I do — the host itself doesn't use Blocky, only the devices that connect through Tailscale.&lt;/li&gt;
&lt;li&gt;Or, pin Blocky's image so it's always locally available and let Docker start it before anything else needs DNS.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="clients-dont-pick-up-the-new-dns"&gt;Clients don't pick up the new DNS&lt;/h3&gt;
&lt;p&gt;Headscale pushes DNS config on every map update, but some clients (especially Linux with NetworkManager) need a nudge.
Running &lt;code&gt;tailscale up&lt;/code&gt; again with &lt;code&gt;--accept-dns=true&lt;/code&gt; forces a re-sync.
On Android, toggling off then on is usually enough.&lt;/p&gt;
&lt;h3 id="have-to-disable-magicdns"&gt;Have to disable MagicDNS&lt;/h3&gt;
&lt;p&gt;If &lt;code&gt;magic_dns&lt;/code&gt; had been left enabled in Headscale configuration it would have been in conflict with Blocky DNS, as both would try to use the same port 53.&lt;/p&gt;
&lt;p&gt;I decided to disable MagicDNS and keep the &lt;code&gt;mapping&lt;/code&gt; in Blocky's config of domains and IPs.
It's a manual process, but my tailnet is small and won't change much, so I'm OK with it if it means a simpler setup.&lt;/p&gt;
&lt;p&gt;An alternative solution would be to run Headscale MagicDNS in a different port, then have Blocky use it.&lt;/p&gt;
&lt;h2 id="whats-next"&gt;What's next&lt;/h2&gt;
&lt;p&gt;With Blocky in place, every device on my tailnet gets ad-blocking and filtered DNS, under any Internet connection anywhere in the world.&lt;/p&gt;
&lt;p&gt;In Part 3 I'll reconfigure Syncthing to use the tailnet exclusively, disabling all its WAN-oriented discovery and relay features now that they're unnecessary.&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="dns"/><category term="howto"/><category term="linux"/><category term="security"/><category term="tailscale"/><category term="vpn"/></entry><entry><title>Self-hosted Tailscale, Part 1: Headscale and clients</title><link href="https://blog.fidelramos.net/software/tailscale-1-headscale-and-clients" rel="alternate"/><published>2026-05-05T00:00:00+00:00</published><updated>2026-07-04T19:43:29.109105+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-05-05:/software/tailscale-1-headscale-and-clients</id><summary type="html">&lt;p&gt;I had been hearing a lot of people raving about &lt;a href="https://tailscale.com/"&gt;Tailscale&lt;/a&gt; as a solution for interconnecting devices, or in other words for creating your own mesh VPN.
It does seem great on paper: easy to set up, fast and lightweight, based on an open protocol (&lt;a href="https://www.wireguard.com/"&gt;WireGuard&lt;/a&gt;), works everywhere, solves the …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I had been hearing a lot of people raving about &lt;a href="https://tailscale.com/"&gt;Tailscale&lt;/a&gt; as a solution for interconnecting devices, or in other words for creating your own mesh VPN.
It does seem great on paper: easy to set up, fast and lightweight, based on an open protocol (&lt;a href="https://www.wireguard.com/"&gt;WireGuard&lt;/a&gt;), works everywhere, solves the NAT problem...
However I was put off by handing a company my data, even if only metadata, and requiring personal information for sign-up.
Is there no sovereign solution?
Well, there is! And I will tell you step by step how to set it up.&lt;/p&gt;
&lt;p&gt;I found the official article on &lt;a href="https://tailscale.com/blog/how-tailscale-works"&gt;how Tailscale works&lt;/a&gt; incredibly valuable.
If you don't know the concepts of data plane, control plane, hub-and-spoke vs. mesh networks, or NAT traversal I recommend you give it a read.
After all, our goal will be to self-host our own control plane.&lt;/p&gt;
&lt;p&gt;This is the first post in a series about setting up a self-hosted Tailscale-compatible mesh VPN on my home server, with no cloud dependencies and no third-party accounts.&lt;/p&gt;
&lt;div class="toc"&gt;&lt;span class="toctitle"&gt;Table of Contents&lt;/span&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#goals"&gt;Goals&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#why-not-just-use-tailscales-hosted-service"&gt;Why not just use Tailscale's hosted service?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#architecture"&gt;Architecture&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#prerequisites"&gt;Prerequisites&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#headscale-in-docker-compose"&gt;Headscale in Docker Compose&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#headscale-configuration"&gt;Headscale configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#dns-records"&gt;DNS records&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#first-run"&gt;First run&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#creating-a-user-and-pre-auth-keys"&gt;Creating a user and pre-auth keys&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#tailscale-on-linux"&gt;Tailscale on Linux&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#tailscale-on-android"&gt;Tailscale on Android&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#other-clients"&gt;Other clients&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#encountered-pitfalls"&gt;Encountered pitfalls&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#wrong-key"&gt;Wrong key&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#android-device-appears-as-invalid-node-in-headscale"&gt;Android device appears as invalid-... node in headscale&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#android-not-connected-with-no-error"&gt;Android "Not connected" with no error&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#android-private-dns-interferes-with-tailscale-bootstrap"&gt;Android Private DNS interferes with Tailscale bootstrap&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#magicdns-not-resolving-on-a-specific-device"&gt;MagicDNS not resolving on a specific device&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#clock-skew"&gt;Clock skew&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#whats-next"&gt;What's next&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id="goals"&gt;Goals&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;mesh VPN&lt;/strong&gt; between my devices (phone, tablet, laptop, desktop, home server) so I can reach any of my devices from any other device from anywhere in a secure way.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No third-party account&lt;/strong&gt; required for login. No Google, no Apple, no GitHub, no Microsoft.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No third-party servers&lt;/strong&gt; involved in coordination. My devices talk to my server, and that's it.&lt;/li&gt;
&lt;li&gt;Works on Linux and Android (the platforms I actually use), using open-source clients. It's worth noting that other clients are closed-source, but I don't care because I don't use those platforms.&lt;/li&gt;
&lt;li&gt;Easy to keep updated, both clients and server.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="why-not-just-use-tailscales-hosted-service"&gt;Why not just use Tailscale's hosted service?&lt;/h2&gt;
&lt;p&gt;Tailscale is a great product and I want to be fair about this: the data plane is end-to-end encrypted with WireGuard, so Tailscale Inc. does not see any actual traffic.&lt;/p&gt;
&lt;p&gt;But:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;They still see metadata.&lt;/strong&gt; Every client contacts the control plane to fetch the network map. That means Tailscale would see the public IP of every device I own, every time it came online, from wherever I was. That's a lot of location history in one company's logs that I would rather avoid if I can help it. And I can, so I will.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Identity depends on a third party.&lt;/strong&gt; The free tier requires signing in with Google, GitHub, Microsoft, Apple, or a similar OIDC provider. If any of those suspends my account, I'd lose access to my own network.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;It's someone else's service.&lt;/strong&gt; If Tailscale has an outage, changes pricing, pivots their product, or gets acquired, my setup would be impacted.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="architecture"&gt;Architecture&lt;/h2&gt;
&lt;p&gt;The core of the architecture is based on &lt;a href="https://headscale.net/"&gt;Headscale&lt;/a&gt;, an open-source re-implementation of the Tailscale coordination server.
It removes all three previous concerns.
The official Tailscale clients work with it unchanged.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/tailscale_part_1/architecture.svg" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/tailscale_part_1/architecture.svg" title="Click for full screen"/&gt;&lt;figcaption&gt;Architecture of a Tailnet with self-hosted Headscale&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Headscale&lt;/strong&gt; runs in Docker Compose on my home server, behind Caddy for HTTPS.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tailscale clients&lt;/strong&gt; run on each device, configured to use my Headscale instance as the control server. Each client tells the server about their network situation, and get connections to the other nodes in return.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DNS&lt;/strong&gt; during Part 1 uses Cloudflare. Part 2 will automatically replace the DNS on all Tailscale clients with a self-hosted tracker-blocking ad-busting Blocky DNS server.&lt;/li&gt;
&lt;li&gt;Clients establish direct peer-to-peer WireGuard tunnels with each other; Tailscale's public &lt;a href="https://tailscale.com/docs/reference/derp-servers"&gt;DERP relays&lt;/a&gt; are used only as a fallback.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;A domain I control (&lt;code&gt;fidelramos.net&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A way for my home server to be reachable from the internet on ports 80 and 443. I use &lt;strong&gt;dynamic DNS&lt;/strong&gt; because my ISP gives me a dynamic public IP. My server periodically updates an A record pointing &lt;code&gt;headscale.fidelramos.net&lt;/code&gt; to my current IP. If you have a static IP, even better.&lt;/li&gt;
&lt;li&gt;The router forwards TCP ports 80 and 443, and UDP port 41641 to my home server. My server sits in the router's DMZ so this is automatic.&lt;/li&gt;
&lt;li&gt;My server, running &lt;strong&gt;Debian&lt;/strong&gt; with Docker and &lt;strong&gt;Docker Compose&lt;/strong&gt; installed and set up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caddy with caddy-docker-proxy&lt;/strong&gt; as a reverse proxy. I wrote about that setup &lt;a href="https://blog.fidelramos.net/software/switch-nginx-caddy-docker-compose"&gt;in this earlier post&lt;/a&gt;. It's the simplest way I have found to have an HTTPS-enabled reverse proxy. It really sparks joy.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="headscale-in-docker-compose"&gt;Headscale in Docker Compose&lt;/h2&gt;
&lt;p&gt;I have a compose project with other services, including a &lt;code&gt;caddy&lt;/code&gt; service with &lt;code&gt;caddy-docker-proxy&lt;/code&gt; as described in that post.&lt;/p&gt;
&lt;p&gt;Add a &lt;code&gt;headscale&lt;/code&gt; service to &lt;code&gt;compose.yml&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;headscale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;headscale/headscale:stable&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;headscale&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;unless-stopped&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;serve&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${HEADSCALE_ROOT}/config:/etc/headscale&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${HEADSCALE_ROOT}/data:/var/lib/headscale&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;caddy&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;headscale.fidelramos.net&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy.reverse_proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"{{upstreams&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;8080}}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;A few highlights:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;image: headscale/headscale:stable&lt;/code&gt; tracks the latest stable release. I combine it with &lt;a href="https://getwud.github.io/wud/"&gt;WUD&lt;/a&gt; for controlled updates (I will write a future post about it). You can of course do a manual &lt;code&gt;docker compose pull&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;No &lt;code&gt;ports:&lt;/code&gt; section. Headscale is only reached internally via Caddy.&lt;/li&gt;
&lt;li&gt;I set my &lt;code&gt;HEADSCALE_ROOT&lt;/code&gt; at &lt;code&gt;/var/opt/headscale&lt;/code&gt;, but you can use whichever directory makes sense for you.&lt;/li&gt;
&lt;li&gt;The two labels are all &lt;code&gt;caddy-docker-proxy&lt;/code&gt; needs to route &lt;code&gt;headscale.fidelramos.net&lt;/code&gt; to the container's port 8080.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="headscale-configuration"&gt;Headscale configuration&lt;/h2&gt;
&lt;p&gt;I put it in &lt;code&gt;/var/opt/headscale/config/config.yaml&lt;/code&gt;, but you can put it wherever works for you, just adjust the volume mapping in the compose configuration.&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;server_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://headscale.fidelramos.net&lt;/span&gt;
&lt;span class="nt"&gt;listen_addr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;0.0.0.0:8080&lt;/span&gt;
&lt;span class="nt"&gt;metrics_listen_addr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;0.0.0.0:9090&lt;/span&gt;
&lt;span class="nt"&gt;grpc_listen_addr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;0.0.0.0:50443&lt;/span&gt;
&lt;span class="nt"&gt;grpc_allow_insecure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;false&lt;/span&gt;

&lt;span class="nt"&gt;noise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;private_key_path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/lib/headscale/noise_private.key&lt;/span&gt;

&lt;span class="nt"&gt;prefixes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;v4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;100.64.0.0/10&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;v6&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;fd7a:115c:a1e0::/48&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;allocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;sequential&lt;/span&gt;

&lt;span class="nt"&gt;derp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;false&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://controlplane.tailscale.com/derpmap/default&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;auto_update_enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;update_frequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;24h&lt;/span&gt;

&lt;span class="nt"&gt;disable_check_updates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;span class="nt"&gt;ephemeral_node_inactivity_timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;30m&lt;/span&gt;

&lt;span class="nt"&gt;database&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;sqlite&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;sqlite&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/lib/headscale/db.sqlite&lt;/span&gt;

&lt;span class="nt"&gt;log&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;info&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;text&lt;/span&gt;

&lt;span class="nt"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;magic_dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;override_local_dns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;base_domain&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;ts.fidelramos.net&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;nameservers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;global&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1.1.1.1&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;1.0.0.1&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;search_domains&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[]&lt;/span&gt;

&lt;span class="nt"&gt;unix_socket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/run/headscale/headscale.sock&lt;/span&gt;
&lt;span class="nt"&gt;unix_socket_permission&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"0770"&lt;/span&gt;

&lt;span class="nt"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;file&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;The bits worth understanding:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;server_url&lt;/code&gt; is the public HTTPS URL for the control plane. It must be reachable by clients.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;base_domain&lt;/code&gt; is the suffix used by MagicDNS for tailnet hostnames (e.g. &lt;code&gt;homeserver.ts.fidelramos.net&lt;/code&gt;). It &lt;strong&gt;must not be equal to or a parent of&lt;/strong&gt; the &lt;code&gt;server_url&lt;/code&gt; host, otherwise clients get confused.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;dns.nameservers.global&lt;/code&gt; pushes a DNS server to every client. For now I'm using Cloudflare; in Part 2 this becomes my self-hosted Blocky instance.&lt;/li&gt;
&lt;li&gt;DERP is disabled for embedded server mode and uses Tailscale's public DERP map as fallback relays. Using their DERPs doesn't leak traffic (it's relayed WireGuard, still end-to-end encrypted) but it's the one non-self-hosted piece, a reasonable compromise.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="dns-records"&gt;DNS records&lt;/h2&gt;
&lt;p&gt;Only one: an A record for &lt;code&gt;headscale.fidelramos.net&lt;/code&gt; pointing to my home server's public IP (via DDNS). That's what clients connect to and what Let's Encrypt will verify via HTTP-01 through Caddy.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;ts.fidelramos.net&lt;/code&gt; (the &lt;code&gt;base_domain&lt;/code&gt;) does &lt;strong&gt;not&lt;/strong&gt; get a public DNS record. It's resolved entirely inside the tailnet by Tailscale's MagicDNS. This means that only my Tailscale clients will be able to access these domains, and they not be published anywhere.&lt;/p&gt;
&lt;h2 id="first-run"&gt;First run&lt;/h2&gt;
&lt;p&gt;At the Docker compose project:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;compose&lt;span class="w"&gt; &lt;/span&gt;up&lt;span class="w"&gt; &lt;/span&gt;-d
sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;logs&lt;span class="w"&gt; &lt;/span&gt;-f&lt;span class="w"&gt; &lt;/span&gt;headscale
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Once the logs show the API listening and Caddy has obtained a certificate, I confirm the control plane is reachable:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;curl&lt;span class="w"&gt; &lt;/span&gt;https://headscale.fidelramos.net/health
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Should return &lt;code&gt;{"status":"pass"}&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="creating-a-user-and-pre-auth-keys"&gt;Creating a user and pre-auth keys&lt;/h2&gt;
&lt;p&gt;Headscale uses &lt;strong&gt;users&lt;/strong&gt; as namespaces that own devices. I create one for myself:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;compose&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;headscale&lt;span class="w"&gt; &lt;/span&gt;headscale&lt;span class="w"&gt; &lt;/span&gt;users&lt;span class="w"&gt; &lt;/span&gt;create&lt;span class="w"&gt; &lt;/span&gt;fidel
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Then generate a pre-auth key each time I need to register a new device:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;compose&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;headscale&lt;span class="w"&gt; &lt;/span&gt;headscale&lt;span class="w"&gt; &lt;/span&gt;preauthkeys&lt;span class="w"&gt; &lt;/span&gt;create&lt;span class="w"&gt; &lt;/span&gt;--user&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--expiration&lt;span class="w"&gt; &lt;/span&gt;1h
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;The key is only valid for an hour.
Long enough to register a device, short enough to not worry about leaking it.&lt;/p&gt;
&lt;h2 id="tailscale-on-linux"&gt;Tailscale on Linux&lt;/h2&gt;
&lt;p&gt;Debian:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nv"&gt;CODENAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;lsb_release&lt;span class="w"&gt; &lt;/span&gt;--codename&lt;span class="w"&gt; &lt;/span&gt;--short&lt;span class="k"&gt;)&lt;/span&gt;
curl&lt;span class="w"&gt; &lt;/span&gt;-fsSL&lt;span class="w"&gt; &lt;/span&gt;https://pkgs.tailscale.com/stable/debian/&lt;span class="nv"&gt;$CODENAME&lt;/span&gt;.noarmor.gpg&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;tee&lt;span class="w"&gt; &lt;/span&gt;/usr/share/keyrings/tailscale-archive-keyring.gpg&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null
curl&lt;span class="w"&gt; &lt;/span&gt;-fsSL&lt;span class="w"&gt; &lt;/span&gt;https://pkgs.tailscale.com/stable/debian/&lt;span class="nv"&gt;$CODENAME&lt;/span&gt;.tailscale-keyring.list&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;tee&lt;span class="w"&gt; &lt;/span&gt;/etc/apt/sources.list.d/tailscale.list
sudo&lt;span class="w"&gt; &lt;/span&gt;apt&lt;span class="w"&gt; &lt;/span&gt;update
sudo&lt;span class="w"&gt; &lt;/span&gt;apt&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;-y&lt;span class="w"&gt; &lt;/span&gt;tailscale
sudo&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;enable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--now&lt;span class="w"&gt; &lt;/span&gt;tailscaled
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Fedora:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;dnf&lt;span class="w"&gt; &lt;/span&gt;config-manager&lt;span class="w"&gt; &lt;/span&gt;addrepo&lt;span class="w"&gt; &lt;/span&gt;--from-repofile&lt;span class="o"&gt;=&lt;/span&gt;https://pkgs.tailscale.com/stable/fedora/tailscale.repo
sudo&lt;span class="w"&gt; &lt;/span&gt;dnf&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;tailscale
sudo&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;enable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--now&lt;span class="w"&gt; &lt;/span&gt;tailscaled
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Arch Linux and derivatives:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;pacman&lt;span class="w"&gt; &lt;/span&gt;-S&lt;span class="w"&gt; &lt;/span&gt;tailscale
sudo&lt;span class="w"&gt; &lt;/span&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;enable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--now&lt;span class="w"&gt; &lt;/span&gt;tailscaled
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;For all systems, once Tailscale is enabled, register with Headscale by using a preauth-key:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;tailscale&lt;span class="w"&gt; &lt;/span&gt;up&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--login-server&lt;span class="o"&gt;=&lt;/span&gt;https://headscale.fidelramos.net&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--auth-key&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;preauth-key&amp;gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--hostname&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;hostname&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Verify:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# on client&lt;/span&gt;
tailscale&lt;span class="w"&gt; &lt;/span&gt;status
&lt;span class="c1"&gt;# on server&lt;/span&gt;
sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;compose&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;headscale&lt;span class="w"&gt; &lt;/span&gt;headscale&lt;span class="w"&gt; &lt;/span&gt;nodes&lt;span class="w"&gt; &lt;/span&gt;list
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;The node should appear in both lists, with a &lt;code&gt;100.64.0.x&lt;/code&gt; address.&lt;/p&gt;
&lt;h2 id="tailscale-on-android"&gt;Tailscale on Android&lt;/h2&gt;
&lt;p&gt;The Android app talks to public Tailscale by default and there's no obvious setting to change it.
I consider this obfuscation a dark pattern, understandable as they want to run a successful business, but still infuriating having to search for how to do it despite knowing the option was there somewhere.&lt;/p&gt;
&lt;p&gt;The trick is the &lt;strong&gt;alternate server&lt;/strong&gt; option, which is hidden:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Install the Tailscale app from F-Droid, the Play Store or, my chosen way, via &lt;a href="https://obtainium.imranr.dev/"&gt;Obtanium&lt;/a&gt; using &lt;a href="https://pkgs.tailscale.com/stable/tailscale-android-universal-latest.apk"&gt;https://pkgs.tailscale.com/stable/tailscale-android-universal-latest.apk&lt;/a&gt; as source.&lt;/li&gt;
&lt;li&gt;Launch the app and cancel the attempt to log in to Tailscale.&lt;/li&gt;
&lt;li&gt;Once you get to the screen with the Connect button, tap the cog for the settings menu, then tap on &lt;strong&gt;Accounts&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In the top-right menu on that screen, choose &lt;strong&gt;Use an alternate server&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enter your Headscale server URL (in my case &lt;code&gt;https://headscale.fidelramos.net&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Sign in. A browser page will open with instructions on what to run in the headscale server to register this client.&lt;/li&gt;
&lt;li&gt;As instructed, run the command in the headscale server:&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;compose&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;headscale&lt;span class="w"&gt; &lt;/span&gt;headscale&lt;span class="w"&gt; &lt;/span&gt;nodes&lt;span class="w"&gt; &lt;/span&gt;register&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--user&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;--key&lt;span class="w"&gt; &lt;/span&gt;nodekey:abcdef...
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;If you want a visual guide, I found this video with step-by-step instructions:&lt;/p&gt;
&lt;div align="center" class="youtube"&gt;
&lt;iframe allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen="" frameborder="0" height="500" referrerpolicy="strict-origin-when-cross-origin" src="https://www.youtube.com/embed/ofVyohBLuPg?si=h4qve8iXcedCvquO&amp;amp;start=1161" title="YouTube video player" width="800"&gt;&lt;/iframe&gt;
&lt;/div&gt;
&lt;p&gt;After registration, toggle the connect switch in the app.
&lt;code&gt;tailscale status&lt;/code&gt; on a Linux device on the tailnet should show the phone, and it should appear in Headscale's node list as mentioned in the Linux client section.&lt;/p&gt;
&lt;p&gt;I will mention one more Android-specific thing: Tailscale gets added as a VPN service, and at least in my devices it got marked as both always-on and to block connections without VPN. I recommend leaving always-on enabled, but &lt;strong&gt;disable blocking connections without VPN&lt;/strong&gt;. As Tailscale is a VPN only between your devices, if it goes down you don't get deanonymized like with a traffic-forwarding VPN, you just lose access to the VPN peers.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/tailscale_part_1/android_vpn_settings.png" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/tailscale_part_1/android_vpn_settings.png" title="Click for full screen"/&gt;&lt;figcaption&gt;Android VPN Settings&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="other-clients"&gt;Other clients&lt;/h2&gt;
&lt;p&gt;I only use Linux and Android, so that's what I've covered and tested.
For other platforms the Tailscale clients work the same way, just point them at the alternate server and register.
The Headscale project maintains &lt;a href="https://headscale.net/stable/usage/connect/"&gt;up-to-date instructions for macOS, iOS, and Windows&lt;/a&gt; which I won't duplicate here.&lt;/p&gt;
&lt;h2 id="encountered-pitfalls"&gt;Encountered pitfalls&lt;/h2&gt;
&lt;p&gt;A few things that tripped me up along the way, collected here so you don't have to.&lt;/p&gt;
&lt;h3 id="wrong-key"&gt;Wrong key&lt;/h3&gt;
&lt;p&gt;If you pass the wrong key to &lt;code&gt;headscale nodes register&lt;/code&gt; you would get the following error:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Cannot register node: node not found in registration cache&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;The expected value is the &lt;code&gt;nodekey:…&lt;/code&gt; string from the client's browser URL, &lt;strong&gt;not&lt;/strong&gt; a pre-auth key. Pre-auth keys are used on the client side with &lt;code&gt;tailscale up --auth-key=...&lt;/code&gt;, not on the server.&lt;/p&gt;
&lt;p&gt;The registration cache is in-memory and expires after ~15 minutes, so if you take a lunch break between the client login and the server command, re-trigger the login on the client to repopulate it.&lt;/p&gt;
&lt;h3 id="android-device-appears-as-invalid-node-in-headscale"&gt;Android device appears as &lt;code&gt;invalid-...&lt;/code&gt; node in headscale&lt;/h3&gt;
&lt;p&gt;Headscale rejects hostnames that aren't valid DNS labels (spaces, unicode, etc.).
Android sends the human-readable device name, which often fails this check (e.g. "Pixel 8 Pro" in my case).
Headscale then automatically renames the node to &lt;code&gt;invalid-&amp;lt;hash&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;You can rename it in the headscale server after registration:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;sudo&lt;span class="w"&gt; &lt;/span&gt;docker&lt;span class="w"&gt; &lt;/span&gt;compose&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;headscale&lt;span class="w"&gt; &lt;/span&gt;headscale&lt;span class="w"&gt; &lt;/span&gt;nodes&lt;span class="w"&gt; &lt;/span&gt;rename&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;&amp;lt;node-id&amp;gt;&lt;span class="w"&gt; &lt;/span&gt;pixel8pro
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Purely cosmetic, but MagicDNS won't work for the original name until you do this.&lt;/p&gt;
&lt;h3 id="android-not-connected-with-no-error"&gt;Android "Not connected" with no error&lt;/h3&gt;
&lt;p&gt;If the app shows the Headscale account but sits in "Not connected" when you toggle the switch, a few things to check in order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check &lt;code&gt;sudo docker compose logs -f headscale&lt;/code&gt; while toggling. You should see the client hitting &lt;code&gt;/ts2021&lt;/code&gt;. If nothing appears, it's not reaching the server. Could be a TLS problem (check caddy's logs), DNS, firewall...&lt;/li&gt;
&lt;li&gt;From the phone's browser, &lt;code&gt;https://headscale.fidelramos.net/health&lt;/code&gt; must succeed.&lt;/li&gt;
&lt;li&gt;Check that DERP loaded: &lt;code&gt;sudo docker compose exec headscale headscale debug derp&lt;/code&gt; should list regions. If empty, the container can't reach &lt;code&gt;controlplane.tailscale.com&lt;/code&gt; for the map, fix the container DNS or Internet egress first.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Android Private DNS&lt;/strong&gt;: this is worth a section of its own.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="android-private-dns-interferes-with-tailscale-bootstrap"&gt;Android Private DNS interferes with Tailscale bootstrap&lt;/h3&gt;
&lt;p&gt;I was using AdGuard DNS as a custom Private DNS, but it broke when Tailscale connected, leaving me without a working DNS setup, so most Internet connections wouldn't work.&lt;/p&gt;
&lt;p&gt;I saw two errors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;In Android network settings, &lt;em&gt;Private DNS&lt;/em&gt; would say &lt;em&gt;couldn't connect&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Shortly after setting the custom Private DNS, a notification would pop up: &lt;em&gt;Network has no internet access. Private DNS server cannot be accessed.&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Luckily I was able to sidestep this issue by self-hosting my own ad-blocking DNS, and having all my Tailscale clients use it.
This was even better as I would be in full control and wouldn't have to pay for AdGuard DNS any longer.&lt;/p&gt;
&lt;p&gt;This solution will be presented in the upcoming Part 2 of this series.&lt;/p&gt;
&lt;h3 id="magicdns-not-resolving-on-a-specific-device"&gt;MagicDNS not resolving on a specific device&lt;/h3&gt;
&lt;p&gt;After changing the Headscale DNS config, clients need to pull new settings.&lt;/p&gt;
&lt;p&gt;On Linux, &lt;code&gt;sudo tailscale up --login-server=... --accept-dns=true&lt;/code&gt; re-syncs.&lt;/p&gt;
&lt;p&gt;On Android, toggle the VPN switch off and on.&lt;/p&gt;
&lt;h3 id="clock-skew"&gt;Clock skew&lt;/h3&gt;
&lt;p&gt;Tailscale's noise handshake fails silently on significant clock drift.
If nothing else explains a connection issue, check that NTP is working on both ends.
On Android, set date/time to "automatic" in the system settings.&lt;/p&gt;
&lt;h2 id="whats-next"&gt;What's next&lt;/h2&gt;
&lt;p&gt;This setup was already functional: my devices could reach each other through my own control server, with no Tailscale nor other third-party account nor traffic.
I could access apps that previously were only accessible through the LAN from anywhere.&lt;/p&gt;
&lt;p&gt;In Part 2 I'll swap the currently configured Cloudflare DNS for a self-hosted Blocky instance to get ads and tracker blocking and internal name resolution on every tailnet device.&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="howto"/><category term="linux"/><category term="security"/><category term="tailscale"/><category term="vpn"/></entry><entry><title>Pre-establishing SSH master connections for Ansible, or using OnlyKey with Ansible without losing your mind</title><link href="https://blog.fidelramos.net/software/ansible-ssh-master-connections" rel="alternate"/><published>2026-04-07T00:00:00+00:00</published><updated>2026-05-04T18:04:56.544558+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-04-07:/software/ansible-ssh-master-connections</id><summary type="html">&lt;p&gt;I use an &lt;a href="https://onlykey.io/"&gt;OnlyKey&lt;/a&gt; as a hardware security device for a variety of purposes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Common passwords are one keypress away, they get typed automatically.&lt;/li&gt;
&lt;li&gt;As 2FA in my password manager (KeepassXC), by applying HMAC on the password.&lt;/li&gt;
&lt;li&gt;SSH connections.&lt;/li&gt;
&lt;li&gt;GPG encryption and signing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/ansible_and_onlykey/onlykey.webp"/&gt;&lt;figcaption&gt;OnlyKey&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;SSH connections are handled through the …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I use an &lt;a href="https://onlykey.io/"&gt;OnlyKey&lt;/a&gt; as a hardware security device for a variety of purposes:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Common passwords are one keypress away, they get typed automatically.&lt;/li&gt;
&lt;li&gt;As 2FA in my password manager (KeepassXC), by applying HMAC on the password.&lt;/li&gt;
&lt;li&gt;SSH connections.&lt;/li&gt;
&lt;li&gt;GPG encryption and signing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/ansible_and_onlykey/onlykey.webp"/&gt;&lt;figcaption&gt;OnlyKey&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;SSH connections are handled through the &lt;a href="https://docs.onlykey.io/onlykey-agent.html"&gt;onlykey-agent&lt;/a&gt;, and it works well enough.
However with Ansible it becomes a nightmare as it asks for dozens upon dozens of keypresses:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/ansible_and_onlykey/ansible_onlykey.webp" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/ansible_and_onlykey/ansible_onlykey.webp" title="Click for full screen"/&gt;&lt;figcaption&gt;Part of an Ansible session with onlykey-agent&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;This pain was making me use Ansible less and less for my automations, so I resolved to find a fix.
The goal was to have &lt;strong&gt;one OnlyKey press per host per script run&lt;/strong&gt;, while keeping the behavior explicit and reasonably safe.&lt;/p&gt;
&lt;p&gt;I'm happy to say that I managed to find a workaround, which I'm documenting here in hope that it's useful to other people.
It might help not just OnlyKey users, but anybody with an &lt;strong&gt;SSH setup that requires manual intervention&lt;/strong&gt; at connection time, and for some reason Ansible SSH connection reuse is not working.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id="the-problem"&gt;The problem&lt;/h2&gt;
&lt;p&gt;Ansible often opens more than one SSH session per host during a run.
Even with sane settings, you may still see repeated connections for tasks, fact gathering, privilege escalation, and other operations.&lt;/p&gt;
&lt;p&gt;With a normal software SSH key, that's mostly invisible.
You would be leaving performance on the table with all those network round-trips and extra encryption going on, but it'd work.&lt;/p&gt;
&lt;p&gt;With &lt;code&gt;onlykey-agent&lt;/code&gt;, each fresh SSH authentication requires physical confirmation.
That makes repeated connections very tiring very quickly.&lt;/p&gt;
&lt;h2 id="what-i-observed"&gt;What I observed&lt;/h2&gt;
&lt;p&gt;The high-level fix is obvious: &lt;strong&gt;reuse SSH connections&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;And the thing is that &lt;a href="https://www.lisenet.com/2022/speed-up-ansible-ssh-with-multiplexing/"&gt;Ansible supports reusing connections&lt;/a&gt; by way of OpenSSH's connection multiplexing.&lt;/p&gt;
&lt;p&gt;In a nutshell, OpenSSH can establish a connection as master, leave a socket open, and have new connections to the same host/user/port automatically reuse it.&lt;/p&gt;
&lt;p&gt;However when I tried to set it up with Ansible it did not work, I kept getting the same prompts on OnlyKey.&lt;/p&gt;
&lt;p&gt;When I looked at the control path directory I saw the sockets were being created, but they disappeared briefly after.&lt;/p&gt;
&lt;p&gt;Then I tried SSH multiplexing &lt;strong&gt;outside Ansible&lt;/strong&gt;, and it worked.&lt;/p&gt;
&lt;p&gt;I set up &lt;code&gt;ControlMaster&lt;/code&gt; in &lt;code&gt;~/.ssh/config&lt;/code&gt;, and once I connected to a host with &lt;code&gt;onlykey-agent&lt;/code&gt; I could open a new connection &lt;strong&gt;without another OnlyKey prompt&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;However once I closed the connection the socket disappeared.&lt;/p&gt;
&lt;p&gt;My best guess is that Ansible is closing the connections somehow, but I haven't found exactly why or how.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id="the-solution"&gt;The solution&lt;/h2&gt;
&lt;p&gt;Given that SSH connections kept the socket open as long as they were in use, I devised a solution that would work consistently:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Configure SSH multiplexing normally.&lt;/li&gt;
&lt;li&gt;Configure Ansible to use the same control path.&lt;/li&gt;
&lt;li&gt;Run Ansible inside a wrapper script that:&lt;ol&gt;
&lt;li&gt;Reads the host list from the Ansible inventory.&lt;/li&gt;
&lt;li&gt;Opens one SSH master per host.&lt;/li&gt;
&lt;li&gt;Keeps each master alive with a remote &lt;code&gt;sleep&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Runs &lt;code&gt;ansible-playbook&lt;/code&gt; with all original arguments unchanged.&lt;/li&gt;
&lt;li&gt;Kills only the SSH master processes it started when it exits.&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="ssh-configuration"&gt;SSH configuration&lt;/h2&gt;
&lt;p&gt;First, make sure your SSH config enables multiplexing and uses a stable control path.&lt;/p&gt;
&lt;p&gt;Add this to &lt;code&gt;~/.ssh/config&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;Host *
    ControlMaster auto
    ControlPersist 10m
    ControlPath ~/.ssh/sockets/%h-%r-%p
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Then create the socket directory:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;mkdir&lt;span class="w"&gt; &lt;/span&gt;-p&lt;span class="w"&gt; &lt;/span&gt;~/.ssh/sockets
chmod&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0700&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;~/.ssh/sockets
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;I also like to configure users and ports in the SSH configuration, e.g.:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;Host blog.fidelramos.net
    User fidel
    Port 22022
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;h2 id="ansible-configuration"&gt;Ansible configuration&lt;/h2&gt;
&lt;p&gt;Use an &lt;code&gt;ansible.cfg&lt;/code&gt; like this:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[defaults]&lt;/span&gt;
&lt;span class="na"&gt;transport&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;ssh&lt;/span&gt;

&lt;span class="k"&gt;[ssh_connection]&lt;/span&gt;
&lt;span class="na"&gt;ssh_args&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;-o ControlMaster=auto -o ServerAliveInterval=30 -o ServerAliveCountMax=3&lt;/span&gt;
&lt;span class="na"&gt;control_path_dir&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;~/.ssh/sockets&lt;/span&gt;
&lt;span class="na"&gt;control_path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;%(directory)s/%%h-%%r-%%p&lt;/span&gt;
&lt;span class="na"&gt;pipelining&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;transport = ssh&lt;/code&gt; forces OpenSSH rather than other transports that won't use SSH multiplexing the same way.
  This should be the default behavior in recent Ansible versions.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;control_path_dir&lt;/code&gt; and &lt;code&gt;control_path&lt;/code&gt; make Ansible use the same socket naming pattern as SSH.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;pipelining = True&lt;/code&gt; reduces some SSH overhead and remote temp-file churn. This is a good idea even without multiplexing to reduce the number of connections and speed-up Ansible execution.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-wrapper-script"&gt;The wrapper script&lt;/h2&gt;
&lt;p&gt;Here is the full script.&lt;/p&gt;
&lt;p&gt;It intentionally does &lt;strong&gt;not&lt;/strong&gt; try to manage per-host usernames or ports itself.
Those are applied by the SSH config, and Ansible reuses that too.&lt;/p&gt;
&lt;p&gt;It passes all arguments through to &lt;code&gt;ansible-playbook&lt;/code&gt; exactly as given.
See usage examples below.&lt;/p&gt;
&lt;p&gt;I also &lt;a href="https://gist.github.com/haplo/5a6a1194b6f84ec3dad2429d1b3af58b"&gt;published the script as a Gist&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id="ansible-ssh-masterssh"&gt;&lt;code&gt;ansible-ssh-masters.sh&lt;/code&gt;&lt;/h3&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="ch"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c1"&gt;# ansible-ssh-masters.sh&lt;/span&gt;
&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;span class="c1"&gt;# Establish one SSH master connection per host, keep it alive with a&lt;/span&gt;
&lt;span class="c1"&gt;# remote sleep, run ansible-playbook with all original arguments, then&lt;/span&gt;
&lt;span class="c1"&gt;# clean up only the masters this script started.&lt;/span&gt;
&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;span class="c1"&gt;# Useful when SSH authentication has a per-connection human cost:&lt;/span&gt;
&lt;span class="c1"&gt;# hardware security keys, passphrase prompts, FIDO2/U2F, etc.&lt;/span&gt;
&lt;span class="c1"&gt;#&lt;/span&gt;
&lt;span class="c1"&gt;# Usage (timeout in seconds):&lt;/span&gt;
&lt;span class="c1"&gt;#   ANSIBLE_SSH_MASTERS_TIMEOUT=3600 ./ansible-ssh-masters.sh playbook.yml [args...]&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-euo&lt;span class="w"&gt; &lt;/span&gt;pipefail

&lt;span class="nv"&gt;TIMEOUT_SECONDS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ANSIBLE_SSH_MASTERS_TIMEOUT&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;3600&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;SOCKET_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.ssh/sockets"&lt;/span&gt;

&lt;span class="nv"&gt;PIDS&lt;/span&gt;&lt;span class="o"&gt;=()&lt;/span&gt;
&lt;span class="nv"&gt;STARTED_HOSTS&lt;/span&gt;&lt;span class="o"&gt;=()&lt;/span&gt;
&lt;span class="nv"&gt;ARGS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;INVENTORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"inventory"&lt;/span&gt;

&lt;span class="nv"&gt;GREEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'\033[0;32m'&lt;/span&gt;
&lt;span class="nv"&gt;YELLOW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'\033[0;33m'&lt;/span&gt;
&lt;span class="nv"&gt;RED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'\033[0;31m'&lt;/span&gt;
&lt;span class="nv"&gt;NC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'\033[0m'&lt;/span&gt;

log&lt;span class="o"&gt;()&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-e&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;GREEN&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;[+]&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NC&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
warn&lt;span class="o"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-e&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;YELLOW&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;[!]&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NC&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
err&lt;span class="o"&gt;()&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-e&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;RED&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;[-]&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NC&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;

parse_inventory_arg&lt;span class="o"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;local&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;i
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;i&amp;lt;&lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;ARGS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;i++&lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;case&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARGS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;-i&lt;span class="p"&gt;|&lt;/span&gt;--inventory&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;i&lt;span class="w"&gt; &lt;/span&gt;+&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;ARGS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;                    &lt;/span&gt;err&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Missing value after &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARGS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="w"&gt;                    &lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="nv"&gt;INVENTORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARGS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;--inventory&lt;span class="o"&gt;=&lt;/span&gt;*&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="nv"&gt;INVENTORY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARGS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;]#--inventory=&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="p"&gt;;;&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

cleanup&lt;span class="o"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;{&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;log&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Closing master connections..."&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;local&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;i&lt;span class="w"&gt; &lt;/span&gt;pid&lt;span class="w"&gt; &lt;/span&gt;host

&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;i&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="p"&gt;!PIDS[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PIDS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;STARTED_HOSTS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;kill&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-0&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;kill&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="nb"&gt;wait&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;log&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Closed master for &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (pid &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;err&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Failed to kill master for &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (pid &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;warn&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Master for &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; was already gone (pid &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;log&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Done."&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nb"&gt;trap&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;cleanup&lt;span class="w"&gt; &lt;/span&gt;EXIT&lt;span class="w"&gt; &lt;/span&gt;INT&lt;span class="w"&gt; &lt;/span&gt;TERM

parse_inventory_arg

mkdir&lt;span class="w"&gt; &lt;/span&gt;-p&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SOCKET_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
chmod&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0700&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SOCKET_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Remove stale sockets (no process behind them)&lt;/span&gt;
find&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SOCKET_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-maxdepth&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-type&lt;span class="w"&gt; &lt;/span&gt;s&lt;span class="w"&gt; &lt;/span&gt;!&lt;span class="w"&gt; &lt;/span&gt;-exec&lt;span class="w"&gt; &lt;/span&gt;fuser&lt;span class="w"&gt; &lt;/span&gt;-s&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-delete&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;!&lt;span class="w"&gt; &lt;/span&gt;ansible-inventory&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INVENTORY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--graph&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;err&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Inventory could not be parsed: &lt;/span&gt;&lt;span class="nv"&gt;$INVENTORY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

mapfile&lt;span class="w"&gt; &lt;/span&gt;-t&lt;span class="w"&gt; &lt;/span&gt;HOSTS&lt;span class="w"&gt; &lt;/span&gt;&amp;lt;&lt;span class="w"&gt; &lt;/span&gt;&amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;ansible-inventory&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INVENTORY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;--graph&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;awk&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;
&lt;span class="s1"&gt;        /@/ { next }&lt;/span&gt;
&lt;span class="s1"&gt;        {&lt;/span&gt;
&lt;span class="s1"&gt;            line = $0&lt;/span&gt;
&lt;span class="s1"&gt;            gsub(/^[[:space:]|]+/, "", line)&lt;/span&gt;
&lt;span class="s1"&gt;            sub(/^--/, "", line)&lt;/span&gt;
&lt;span class="s1"&gt;            if (line != "") {&lt;/span&gt;
&lt;span class="s1"&gt;                print line&lt;/span&gt;
&lt;span class="s1"&gt;            }&lt;/span&gt;
&lt;span class="s1"&gt;        }&lt;/span&gt;
&lt;span class="s1"&gt;    '&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;sort&lt;span class="w"&gt; &lt;/span&gt;-u
&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;HOSTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;==&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;err&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"No hosts found in inventory: &lt;/span&gt;&lt;span class="nv"&gt;$INVENTORY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;

log&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Found &lt;/span&gt;&lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;HOSTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; hosts in inventory: &lt;/span&gt;&lt;span class="nv"&gt;$INVENTORY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
log&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Per-host connection lifetime: &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMEOUT_SECONDS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; seconds"&lt;/span&gt;
log&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Establishing master connections (authentication may be required)..."&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt;

&lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;host&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOSTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="o"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;count&lt;/span&gt;&lt;span class="o"&gt;+=&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssh&lt;span class="w"&gt; &lt;/span&gt;-O&lt;span class="w"&gt; &lt;/span&gt;check&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ControlPath=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SOCKET_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/%h-%r-%p"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;log&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;HOSTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: reusing existing master"&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;continue&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;warn&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;HOSTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: connecting..."&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;ssh&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;ControlMaster&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yes&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ControlPath=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SOCKET_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/%h-%r-%p"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;ServerAliveInterval&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;ServerAliveCountMax&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;ForwardAgent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;ForwardX11&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;no&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;ClearAllForwardings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yes&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sleep &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TIMEOUT_SECONDS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$!&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;_&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;$(&lt;/span&gt;seq&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;do&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssh&lt;span class="w"&gt; &lt;/span&gt;-O&lt;span class="w"&gt; &lt;/span&gt;check&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ControlPath=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SOCKET_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/%h-%r-%p"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="k"&gt;break&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;sleep&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ssh&lt;span class="w"&gt; &lt;/span&gt;-O&lt;span class="w"&gt; &lt;/span&gt;check&lt;span class="w"&gt; &lt;/span&gt;-o&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ControlPath=&lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SOCKET_DIR&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/%h-%r-%p"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;&lt;span class="p"&gt;&amp;amp;&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;log&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;HOSTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: master established (pid &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nv"&gt;PIDS&lt;/span&gt;&lt;span class="o"&gt;+=(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nv"&gt;STARTED_HOSTS&lt;/span&gt;&lt;span class="o"&gt;+=(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;err&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;HOSTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: failed to establish master"&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;kill&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-0&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;kill&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;then&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;&lt;span class="nb"&gt;wait&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&amp;gt;/dev/null&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="k"&gt;else&lt;/span&gt;
&lt;span class="w"&gt;                &lt;/span&gt;err&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="nv"&gt;$count&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="si"&gt;${#&lt;/span&gt;&lt;span class="nv"&gt;HOSTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;host&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: failed to stop failed SSH process (pid &lt;/span&gt;&lt;span class="si"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt;
log&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Running: ansible-playbook &lt;/span&gt;&lt;span class="nv"&gt;$*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;+e
ansible-playbook&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;ANSIBLE_EXIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-e

&lt;span class="nb"&gt;exit&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ANSIBLE_EXIT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Make it executable:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;chmod&lt;span class="w"&gt; &lt;/span&gt;+x&lt;span class="w"&gt; &lt;/span&gt;ansible-ssh-masters.sh
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;h3 id="screenshot"&gt;Screenshot&lt;/h3&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/ansible_and_onlykey/ansible_ssh_masters.webp" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/ansible_and_onlykey/ansible_ssh_masters.webp" title="Click for full screen"/&gt;&lt;figcaption&gt;ansible-ssh-masters in action&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;h3 id="usage"&gt;Usage&lt;/h3&gt;
&lt;p&gt;Just replace &lt;code&gt;ansible-playbook&lt;/code&gt; with &lt;code&gt;ansible-ssh-masters.sh&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;./ansible-ssh-masters.sh&lt;span class="w"&gt; &lt;/span&gt;playbook.yml
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;In my case I prepend &lt;code&gt;onlykey-agent&lt;/code&gt; with the right key:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;onlykey-agent&lt;span class="w"&gt; &lt;/span&gt;fidelramos.net&lt;span class="w"&gt; &lt;/span&gt;--&lt;span class="w"&gt; &lt;/span&gt;./ansible-ssh-masters.sh&lt;span class="w"&gt; &lt;/span&gt;playbook.yml
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;You can also start an &lt;a href="https://docs.onlykey.io/onlykey-agent.html#start-multiple-ssh-sessions-from-a-sub-shell"&gt;&lt;code&gt;onlykey-agent&lt;/code&gt; subshell&lt;/a&gt;, then run &lt;code&gt;ansible-ssh-masters.sh&lt;/code&gt; inside:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;onlykey-agent&lt;span class="w"&gt; &lt;/span&gt;fidelramos.net&lt;span class="w"&gt; &lt;/span&gt;-s
./ansible-ssh-masters.sh&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;inventory&lt;span class="w"&gt; &lt;/span&gt;playbook.yml
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;You can pass any number of arguments, or a different inventory file or expression, they get passed as-is to &lt;code&gt;ansible-playbook&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;./ansible-ssh-masters.sh&lt;span class="w"&gt; &lt;/span&gt;-i&lt;span class="w"&gt; &lt;/span&gt;production.ini&lt;span class="w"&gt; &lt;/span&gt;playbook.yml&lt;span class="w"&gt; &lt;/span&gt;--limit&lt;span class="w"&gt; &lt;/span&gt;web&lt;span class="w"&gt; &lt;/span&gt;--tags&lt;span class="w"&gt; &lt;/span&gt;deploy&lt;span class="w"&gt; &lt;/span&gt;-v
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;To change how long the pre-opened master sessions live, set the timeout in &lt;strong&gt;seconds&lt;/strong&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nv"&gt;ANSIBLE_ONLYKEY_TIMEOUT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="m"&gt;7200&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;./ansible-ssh-masters.sh&lt;span class="w"&gt; &lt;/span&gt;long-playbook.yml
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;h3 id="key-design-points-of-the-script"&gt;Key design points of the script&lt;/h3&gt;
&lt;p&gt;A few design choices are deliberate here.&lt;/p&gt;
&lt;h4 id="1-close-connections-on-completion-or-exit"&gt;1. Close connections on completion or exit&lt;/h4&gt;
&lt;p&gt;With the use of &lt;code&gt;trap&lt;/code&gt; the script makes sure connections are closed as soon as they are not needed anymore, or if the script gets interrupted (Ctrl-C, killed or closed shell for example).&lt;/p&gt;
&lt;h4 id="2-cleans-up-what-it-started"&gt;2. Cleans up what it started&lt;/h4&gt;
&lt;p&gt;The script kills only the SSH master processes it created.&lt;/p&gt;
&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; sweep the whole socket directory.
That matters if you have other SSH sessions or tools using the same multiplexing directory.&lt;/p&gt;
&lt;h4 id="3-flexible-inventory-parsing"&gt;3. Flexible inventory parsing&lt;/h4&gt;
&lt;p&gt;The script detects the use of &lt;code&gt;-i/--inventory&lt;/code&gt; argument and it parses it with &lt;code&gt;ansible-inventory&lt;/code&gt; to get the hosts where the playbook will execute.
That means any of the &lt;code&gt;--inventory&lt;/code&gt; formats will work, it's not limited to an inventory file.&lt;/p&gt;
&lt;h4 id="4-uses-the-ssh-configuration-as-source-of-truth"&gt;4. Uses the SSH configuration as source of truth&lt;/h4&gt;
&lt;p&gt;Instead of relying on parsing configuration it transparently uses the SSH configuration.&lt;/p&gt;
&lt;p&gt;The script does not try to guess usernames, ports, or host aliases from Ansible.&lt;/p&gt;
&lt;p&gt;That all stays in SSH config where it belongs.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id="security-notes"&gt;Security notes&lt;/h2&gt;
&lt;p&gt;This setup is definitely a &lt;strong&gt;usability improvement&lt;/strong&gt;, but it does &lt;strong&gt;diminish security&lt;/strong&gt; while the script is running.&lt;/p&gt;
&lt;h3 id="the-main-tradeoff"&gt;The main tradeoff&lt;/h3&gt;
&lt;p&gt;After you approve a host once with OnlyKey, that SSH master connection stays open for the configured lifetime or until the script exits.&lt;/p&gt;
&lt;p&gt;That means:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Later SSH sessions to that host can reuse the master.&lt;/li&gt;
&lt;li&gt;They do not need another OnlyKey press while that master is still alive.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is the whole point of the solution, but it also means your local session becomes more important to protect during that window.&lt;/p&gt;
&lt;p&gt;Other software in the computer with access to the SSH sockets directory could gain direct access to the remote host, which is kinda the point of using a hardware security device in the first place.&lt;/p&gt;
&lt;h3 id="things-this-setup-does-to-limit-risk"&gt;Things this setup does to limit risk&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Sockets live in &lt;code&gt;~/.ssh/sockets&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;That directory is set to &lt;code&gt;0700&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Agent forwarding is disabled.&lt;/li&gt;
&lt;li&gt;X11 forwarding is disabled.&lt;/li&gt;
&lt;li&gt;The script cleans up automatically on exit.&lt;/li&gt;
&lt;li&gt;The timeout is explicit and finite in case automatic cleanup fails for some reason.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Also personally I use &lt;a href="https://github.com/netblue30/firejail/"&gt;Firejail&lt;/a&gt; to sandbox most of the software running on my computer.
My Firejail setup blacklists access to &lt;code&gt;~/.ssh&lt;/code&gt; for most programs, so I feel a bit safer with this approach.&lt;/p&gt;
&lt;h3 id="why-automatic-cleanup-stays-enabled"&gt;Why automatic cleanup stays enabled&lt;/h3&gt;
&lt;p&gt;The script always closes all masters after the script ends (via &lt;code&gt;trap&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;This is a design decision, and the script would be even simpler by just leaving the connections open until they time out.
This would also mean that further Ansible invocations would not need any further OnlyKey presses.&lt;/p&gt;
&lt;p&gt;Personally I don't want that, and I would not recommend it as the default as it further reduces security.
The shorter that connections stay open the less likely they can be taken over.&lt;/p&gt;
&lt;hr/&gt;
&lt;h2 id="final-thoughts"&gt;Final thoughts&lt;/h2&gt;
&lt;p&gt;I feel sad that I couldn't get to the bottom of why the Ansible connection reuse was not working properly, but I couldn't allocate more time to that research.&lt;/p&gt;
&lt;p&gt;I do feel happy to have found this workaround, which is a reasonable sweet spot between convenience and security.
If this hadn't worked I was feeling close to going back to regular SSH keys, which would have reduced the security of my servers considerably.&lt;/p&gt;
&lt;p&gt;If like me you have a cumbersome SSH connection scheme I hope this script helps you enjoy Ansible more.&lt;/p&gt;
&lt;p&gt;If you made it this far and have an idea on how to fix it &lt;em&gt;The Proper Way™&lt;/em&gt;, please leave a comment!&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="ansible"/><category term="automation"/><category term="howto"/><category term="linux"/><category term="security"/></entry><entry><title>Announcing Reflex, a Pelican theme</title><link href="https://blog.fidelramos.net/software/announce-reflex-theme" rel="alternate"/><published>2026-02-16T00:00:00+00:00</published><updated>2026-02-19T00:00:00+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-02-16:/software/announce-reflex-theme</id><summary type="html">&lt;p&gt;I'm happy to announce the public availability of my Pelican theme: &lt;a href="https://github.com/haplo/python-theme-reflex"&gt;Reflex&lt;/a&gt;.
It is &lt;a href="https://pypi.org/project/pelican-reflex/"&gt;published on PyPI&lt;/a&gt;, which should be the best way for most users to install it.&lt;/p&gt;
&lt;p&gt;When I first started this blog I decided on Pelican because I had lots of experience with Python, so it would …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I'm happy to announce the public availability of my Pelican theme: &lt;a href="https://github.com/haplo/python-theme-reflex"&gt;Reflex&lt;/a&gt;.
It is &lt;a href="https://pypi.org/project/pelican-reflex/"&gt;published on PyPI&lt;/a&gt;, which should be the best way for most users to install it.&lt;/p&gt;
&lt;p&gt;When I first started this blog I decided on Pelican because I had lots of experience with Python, so it would make it easier for me to get involved and contribute.
I then looked at all the &lt;a href="http://www.pelicanthemes.com/"&gt;Pelican themes&lt;/a&gt; and settled on the &lt;a href="https://github.com/alexandrevicenzi/Flex"&gt;Flex theme by Alexandre
Vizenzi&lt;/a&gt; because it checked all my requirements:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Minimalistic and beautiful.&lt;/li&gt;
&lt;li&gt;Responsive layout.&lt;/li&gt;
&lt;li&gt;Archives, categories, tags support.&lt;/li&gt;
&lt;li&gt;Pygments support for code highlighting.&lt;/li&gt;
&lt;li&gt;Light/Dark modes support.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As it was inevitable I eventually hit some missing features and small tweaks I wanted to implement.
That is the beauty of open-source, and I started &lt;a href="https://github.com/alexandrevicenzi/Flex/pulls?q=is%3Apr+author%3Ahaplo+"&gt;contributing to Flex&lt;/a&gt;.
However some of my desired changes were too deep, and they didn't fit in Flex as the project was in maintenance mode.
After some thinking I decided to fork the theme to introduce my more breaking changes, and that is when Reflex was born.&lt;/p&gt;
&lt;p&gt;The &lt;strong&gt;main differences with Flex&lt;/strong&gt; for now:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Installable via PyPI (so &lt;code&gt;pip install pelican-theme-reflex&lt;/code&gt; works).&lt;/li&gt;
&lt;li&gt;Style for Table of Contents created by the &lt;em&gt;toc&lt;/em&gt; markdown extension.&lt;/li&gt;
&lt;li&gt;Styles for &lt;code&gt;figure&lt;/code&gt; and &lt;code&gt;figcaption&lt;/code&gt; HTML elements.&lt;/li&gt;
&lt;li&gt;Shynet tracking support (see &lt;a href="https://blog.fidelramos.net/software/self-hosted-comments"&gt;my article on self-hosted comments&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;In-repo documentation instead of Github wiki.&lt;/li&gt;
&lt;li&gt;Display language flags for alternative article languages.&lt;/li&gt;
&lt;li&gt;X social icon.&lt;/li&gt;
&lt;li&gt;Improvements to developer experience: gulp watch support for instant changes, &lt;em&gt;pre-commit&lt;/em&gt; hooks, AGENTS.md for AI development, revamped GitHub Actions workflows.&lt;/li&gt;
&lt;li&gt;Updated Pygments styles and FontAwesome.&lt;/li&gt;
&lt;li&gt;Accessibility improvements.&lt;/li&gt;
&lt;li&gt;Updated Isso comments integration, with support for highlighting comments by the author and reply notifications by default.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I don't have a long roadmap planned for Reflex, I will mostly implement changes that I find useful for this blog.
I'm open to new integrations and ideas, so collaborators are welcome!&lt;/p&gt;
&lt;p&gt;For known bugs and planned features take a look at the &lt;a href="https://github.com/haplo/pelican-theme-reflex/issues"&gt;open issues&lt;/a&gt;.
For any questions feel free to open a &lt;a href="https://github.com/haplo/pelican-theme-reflex/discussions"&gt;discussion&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you use Reflex feel free to add your site to the &lt;a href="https://github.com/haplo/pelican-theme-reflex?tab=readme-ov-file#sites-using-reflex"&gt;list of Reflex users&lt;/a&gt;, I would love to hear from you!&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Update Feb 19th 2016&lt;/strong&gt;: &lt;a href="https://haplo.github.io/pelican-theme-reflex/reflex-3.1.0.html"&gt;Reflex 3.1.0&lt;/a&gt; was released with a few extra improvements.&lt;/p&gt;</content><category term="Software"/><category term="free-software"/><category term="pelican"/></entry><entry><title>Self-hosted Home, Part 1: Design and Planning</title><link href="https://blog.fidelramos.net/software/self-hosted-home-1" rel="alternate"/><published>2026-01-25T00:00:00+00:00</published><updated>2026-04-06T20:42:00.263395+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2026-01-25:/software/self-hosted-home-1</id><summary type="html">&lt;p&gt;Those who have been paying attention to the blog know that I bought a house and have been planning its full renovation.
As a computer nerd what excites me the most is the opportunity to implement &lt;strong&gt;my dream  smart home, networking and home server&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I have a clear idea of …&lt;/p&gt;</summary><content type="html">&lt;p&gt;Those who have been paying attention to the blog know that I bought a house and have been planning its full renovation.
As a computer nerd what excites me the most is the opportunity to implement &lt;strong&gt;my dream  smart home, networking and home server&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I have a clear idea of what I want to achieve: an &lt;strong&gt;open‑source homelab&lt;/strong&gt; that replaces &lt;strong&gt;closed cloud ecosystems&lt;/strong&gt; with &lt;strong&gt;local‑first services&lt;/strong&gt;.
Smart home automation, security cameras and video doorbell, contacts and calendars, media players, photo collection, documents, and more, all running &lt;strong&gt;on premises&lt;/strong&gt; and &lt;strong&gt;fully under my control&lt;/strong&gt;.
Do note that I &lt;a href="https://blog.fidelramos.net/software/homelab"&gt;already have a homelab&lt;/a&gt;, where some of these services have been running for years already.
This new project will replace it completely, it will be &lt;strong&gt;bigger&lt;/strong&gt; and &lt;strong&gt;better&lt;/strong&gt;!
Do read that article if you have the chance as it gives a rationale of what moved me to start self-hosting services and the historical context of how I got here.&lt;/p&gt;
&lt;p&gt;In this series of posts I will go into all the technical details, starting with the design I arrived at after months of research.&lt;/p&gt;
&lt;p&gt;The setup is completely personal and opinionated, tailored to my particular needs and taste, so don't take it as a recommendation.
I do hope that it's a fun read and you learn something cool!&lt;/p&gt;
&lt;p&gt;My goals are:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Local‑first&lt;/strong&gt;: Smart home, cameras, media, and personal data should work without any cloud dependency. Internet access could go down and everything would keep working locally. Also I can trust my data won't leave my home.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Strong isolation&lt;/strong&gt;: IoT and cameras are treated as hostile. They get &lt;strong&gt;no Internet egress&lt;/strong&gt; and limited access to internal services. Cloud connectivity is to be avoided whenever possible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Standards‑based and open-source&lt;/strong&gt;: ONVIF, RTSP, IPv6, WireGuard, ZFS, Proxmox, TrueNAS, IPFire, Home Assistant, Frigate, Nextcloud, Jellyfin, Immich... &lt;strong&gt;avoid proprietary black boxes&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cost&lt;/strong&gt;: While the whole setup won't be cheap by any means, I will look for &lt;strong&gt;refurbished server‑grade hardware&lt;/strong&gt; and favor &lt;strong&gt;low-power&lt;/strong&gt; devices. The system will have a lot of responsibilities so I need quality reliable hardware.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Future‑proof&lt;/strong&gt;: 10 GbE-ready via Cat6a, core‑rich servers, room for expansion, IPv6 everywhere.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="toc"&gt;&lt;span class="toctitle"&gt;Table of Contents&lt;/span&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#1-overview"&gt;1. Overview&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#selfhosted-services"&gt;Self‑hosted services&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#2-networking"&gt;2. Networking&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#ipfire-routerfirewall"&gt;IPFire router/firewall&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#unifi-switching-and-wifi"&gt;UniFi switching and WiFi&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#vlan-design-and-trust-model"&gt;VLAN design and trust model&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#wireguard-vpn"&gt;WireGuard VPN&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#3-application-server"&gt;3. Application Server&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#proxmox-and-licensing-considerations"&gt;Proxmox and licensing considerations&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#server-hardware-options"&gt;Server hardware options&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#redundancy-and-reliability"&gt;Redundancy and reliability&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#4-storage"&gt;4. Storage&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#why-not-synology"&gt;Why not Synology?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#enter-truenas"&gt;Enter TrueNAS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#storage-layout"&gt;Storage layout&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#5-cameras-and-doorbell"&gt;5. Cameras and Doorbell&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#initial-plan-unifi-or-reolink"&gt;Initial plan: UniFi or Reolink?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#frigate-and-dahua-cameras"&gt;Frigate and Dahua cameras&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#network-design-for-cameras"&gt;Network design for cameras&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#object-detection"&gt;Object detection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#video-doorbell"&gt;Video doorbell&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#6-power-and-resilience"&gt;6. Power and Resilience&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#on-power-consumption"&gt;On power consumption&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#power-emergency-ups-and-shutdown-strategy"&gt;Power emergency: UPS and shutdown strategy&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#7-backups-and-recovery"&gt;7. Backups and Recovery&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#8-operations-monitoring-and-security-practices"&gt;8. Operations, Monitoring, and Security Practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#9-next-steps"&gt;9. Next Steps&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;p&gt;Other articles in the series:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Part 1: Design and Planning&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Coming soon, &lt;a href="https://blog.fidelramos.net/pages/subscribe.html"&gt;stay tuned&lt;/a&gt; for updates!&lt;/li&gt;
&lt;/ol&gt;
&lt;h1 id="1-overview"&gt;1. Overview&lt;/h1&gt;
&lt;p&gt;The homelab will consist of multiple components mounted on a &lt;a href="https://en.wikipedia.org/wiki/19-inch_rack"&gt;19-inch 42U rack&lt;/a&gt; in a dedicated room in the basement of the house.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Core networking and security&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;I want to be in control of the home network: prevent Internet access for smart home and cameras and limit access to internal services.&lt;/li&gt;
&lt;li&gt;A dedicated server running IPFire will provide routing, firewalling, IDS and VPN.&lt;/li&gt;
&lt;li&gt;UniFi switch and WiFi access points (APs).&lt;/li&gt;
&lt;li&gt;VLANs for strict segmentation (LAN, IoT, cameras, guests, management).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Application server&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;I will migrate the self-hosted services running in my &lt;a href="https://blog.fidelramos.net/software/homelab"&gt;current home server&lt;/a&gt;, and expand them.&lt;/li&gt;
&lt;li&gt;Proxmox VE on a server‑grade box (Dell PowerEdge class).&lt;/li&gt;
&lt;li&gt;Mix of VMs and LXC containers for services.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;I have considerable storage needs as I keep a large photo collection and a local media library.&lt;/li&gt;
&lt;li&gt;TrueNAS running as a VM on Proxmox, backed by a dedicated disk pool.&lt;/li&gt;
&lt;li&gt;ZFS for redundancy, snapshots, and replication.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cameras and doorbell&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Dahua cameras and doorbell with ONVIF/RTSP/IPv6 and 2‑way audio.&lt;/li&gt;
&lt;li&gt;Dedicated PoE switch.&lt;/li&gt;
&lt;li&gt;Frigate for NVR‑like recording and object detection.&lt;/li&gt;
&lt;li&gt;Coral TPU for efficient inference.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Power resilience&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;Solar panels and batteries will keep at least core infrastructure running.&lt;/li&gt;
&lt;li&gt;An UPS will provide critical backup to keep the network and cameras running and to allow graceful shutdown of the app server.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For those who enjoy a little diagram:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/self_hosted_home_1/rack.svg" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/self_hosted_home_1/rack.svg" title="Click for full screen"&gt;&lt;figcaption&gt;Diagram of the rack&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="selfhosted-services"&gt;Self‑hosted services&lt;/h2&gt;
&lt;p&gt;Planned services include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.truenas.com/"&gt;&lt;strong&gt;TrueNAS&lt;/strong&gt;&lt;/a&gt;, detailed below in the &lt;em&gt;Storage&lt;/em&gt; section.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.home-assistant.io/"&gt;&lt;strong&gt;Home Assistant&lt;/strong&gt;&lt;/a&gt; for smart home automation and integration of all platforms and data.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://frigate.video/"&gt;&lt;strong&gt;Frigate NVR&lt;/strong&gt;&lt;/a&gt; for cameras, object detection and doorbell communication (also integrated into Home Assistant).&lt;/li&gt;
&lt;li&gt;&lt;a href="https://nextcloud.com/"&gt;&lt;strong&gt;Nextcloud&lt;/strong&gt;&lt;/a&gt; as a personal cloud (contacts, calendar, files, notes, etc.).&lt;/li&gt;
&lt;li&gt;&lt;a href="https://immich.app/"&gt;&lt;strong&gt;Immich&lt;/strong&gt;&lt;/a&gt; as photo library.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jellyfin.org/"&gt;&lt;strong&gt;Jellyfin&lt;/strong&gt;&lt;/a&gt; for media playback.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.music-assistant.io/"&gt;&lt;strong&gt;Music Assistant&lt;/strong&gt;&lt;/a&gt; for music playback.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.paperless-ngx.com/"&gt;&lt;strong&gt;Paperless-ngx&lt;/strong&gt;&lt;/a&gt; to manage documents.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/AdguardTeam/AdGuardHome"&gt;&lt;strong&gt;AdGuard Home&lt;/strong&gt;&lt;/a&gt; for ad blocking house-wide.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auxiliary services&lt;/strong&gt;: monitoring, uptime checks, logging, backup orchestration, NTP...&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I mean to give access to most of these services to my family.
That would amortize the setup and running costs, while bringing more privacy into their lives.&lt;/p&gt;
&lt;h1 id="2-networking"&gt;2. Networking&lt;/h1&gt;
&lt;p&gt;Networking is the foundation for everything else, it is key to the performance, security and comfort of the system.&lt;/p&gt;
&lt;p&gt;I'm taking the opportunity that a full-house renovation brings to install &lt;strong&gt;Cat6a Ethernet cables&lt;/strong&gt; throughout the house.
Every room will get &lt;strong&gt;at least one RJ-45 port&lt;/strong&gt; in a wall socket, and some of them multiple ones, depending on expected usage.
Cat6a cables guarantee future upgrades to &lt;strong&gt;10 GbE&lt;/strong&gt; by replacing the switch, NICs and APs, the wiring would already be in place.
Initially the connections will be at 2.5 GbE, with the exception of two 10 GbE for the two desktop computers.&lt;/p&gt;
&lt;p&gt;All cables will run through the walls to the server room, to rack-mounted &lt;a href="https://commons.wikimedia.org/wiki/File:19-inch_rackmount_Ethernet_switches_and_patch_panels.jpg"&gt;patch panels&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;For router and firewall I will install &lt;strong&gt;IPFire&lt;/strong&gt; in a dedicated server, it will be the core of the setup.
A &lt;strong&gt;UniFi&lt;/strong&gt; switch and multiple UniFi WiFi access points will aggregate the traffic.
There will be a secondary switch for the security cameras, it will be discussed in the &lt;em&gt;Cameras and Doorbell&lt;/em&gt; section.&lt;/p&gt;
&lt;h2 id="ipfire-routerfirewall"&gt;IPFire router/firewall&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://www.ipfire.org/"&gt;IPFire&lt;/a&gt; is an open-source router and firewalling software based on GNU/Linux.
I will put it on a small, low‑power but robust rack server with these (ideal) requirements:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;At least 2 SFP+ NICs (one for WAN, another for LAN, optionally another NIC for dedicated MGMT or DMZ).&lt;/li&gt;
&lt;li&gt;Enough CPU to handle up to 10 Gbps WAN speed.&lt;/li&gt;
&lt;li&gt;Dual SSD or NVMe drives to set them up in mirror for redundancy.&lt;/li&gt;
&lt;li&gt;Dual PSUs for power redundancy.&lt;/li&gt;
&lt;li&gt;As low power as possible, especially on idle.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I'm still researching options for this server.
IPFire offers its own &lt;a href="https://store.lightningwirelabs.com/products/groups/firewall-appliances"&gt;appliances&lt;/a&gt;, but they seem to cost too much for what they offer, so I'm looking at rack servers.&lt;/p&gt;
&lt;p&gt;The best fit I have found so far is the &lt;a href="https://www.supermicro.com/en/products/motherboard/X10SDV-7TP8F"&gt;Supermicro X10SDV-7TP8F&lt;/a&gt;, which is a system-on-chip motherboard with an Intel Xeon D-1587 CPU.
It checks most requirements: enough compute power for 10 Gbps WAN; low power consumption (10W idle, 45W TDP); embedded 2x 10 GbE SFP+ NIC. It's missing dual PSUs and dual drives, but nothing is perfect.&lt;/p&gt;
&lt;p&gt;The IPFire box will connect directly to the ISP router (in bridge mode) or a dedicated &lt;a href="https://en.wikipedia.org/wiki/Network_interface_device#Optical_network_terminals"&gt;ONT&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It will handle these responsibilities:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Terminate the WAN connection (PPPoE or DHCP, depending on ISP).&lt;/li&gt;
&lt;li&gt;Provide &lt;strong&gt;stateful firewalling&lt;/strong&gt; for both IPv4 and IPv6.&lt;/li&gt;
&lt;li&gt;Perform &lt;strong&gt;DHCP&lt;/strong&gt; and &lt;strong&gt;DNS&lt;/strong&gt; for internal networks.&lt;/li&gt;
&lt;li&gt;Handle &lt;strong&gt;WireGuard&lt;/strong&gt; VPN tunnels for remote access to the home network.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IDS&lt;/strong&gt; (Intrusion Detection System).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I want IPv6 to be a first‑class citizen: IPFire will obtain a delegated prefix from the ISP and split it across the internal VLANs.
This, coupled with a deny-by-default policy, will make it easier to open services to the outside in a controlled and safe manner without the hassles and performance impact of IPv4 NAT.&lt;/p&gt;
&lt;h2 id="unifi-switching-and-wifi"&gt;UniFi switching and WiFi&lt;/h2&gt;
&lt;p&gt;Downstream of IPFire there will be a &lt;a href="https://techspecs.ui.com/unifi/switching/usw-pro-hd-24-poe"&gt;UniFi Pro HD 24 PoE switch&lt;/a&gt; which will:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Aggregate all wired segments (rooms, rack, camera uplink, WiFi APs).&lt;/li&gt;
&lt;li&gt;Provide PoE for the APs and potentially other devices.&lt;/li&gt;
&lt;li&gt;Implement VLAN tagging and firewall‑like segmentation at Layer 2/3 in cooperation with IPFire.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I searched far and wide for rack-mounted switches, but nothing came close spec-wise to this UniFi.
It's got &lt;strong&gt;4x 10 GbE SFP+&lt;/strong&gt; ports for connecting the IPFire and application servers, &lt;strong&gt;2x GbE RJ-45&lt;/strong&gt; ports for my desktop computers, and &lt;strong&gt;22x 2.5 GbE RJ-45&lt;/strong&gt; ports with PoE.
Most other 24-port switches I found have only 1G ports, and no 10 GbE.
Having 2.5 GbE from the get go will give great performance network-wide for a long time.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/self_hosted_home_1/unifi_switch_pro_hd_24_poe.avif" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/self_hosted_home_1/unifi_switch_pro_hd_24_poe.avif" title="Click for full screen"&gt;&lt;figcaption&gt;UniFi Pro HD 24 PoE switch&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;For WiFi APs I considered &lt;a href="https://www.grandstream.com/products/networking-solutions/indoor-wifi-access-points"&gt;Grandstream APs&lt;/a&gt;, which are reportedly of good quality, but I discarded them because they don't support the 6 GHz band, which is the novelty of the WiFi 7 standard.
I eventually settled on UniFi WiFi APs (most likely the &lt;a href="https://techspecs.ui.com/unifi/wifi/u7-pro"&gt;U7 Pro&lt;/a&gt;), because they work with a single Ethernet cable (2.5 GbE for the U7 Pro, but in the future I could upgrade to an AP with 10 GbE), and they can be managed together with the UniFi switch.&lt;/p&gt;
&lt;p&gt;The APs will allow for seamless roaming around the house, and will have multiple SSIDs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Main SSID&lt;/strong&gt;: For trusted devices (family laptops, phones, tablets).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IoT SSID&lt;/strong&gt;: For wireless smart devices, mapped to the IoT VLAN.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Guest SSID&lt;/strong&gt;: Isolated from internal services, Internet‑only.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/self_hosted_home_1/unifi_u7_pro.avif" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/self_hosted_home_1/unifi_u7_pro.avif" title="Click for full screen"&gt;&lt;figcaption&gt;UniFi U7 Pro&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Proxmox will run a &lt;a href="https://help.ui.com/hc/en-us/articles/360012282453-Self-Hosting-a-UniFi-Network-Server"&gt;UniFi Network Server&lt;/a&gt; in a VM, that will allow for configuration of the switch and APs without cloud connectivity.
I'm really trusting Ubiquiti not to screw me up with future changes that require cloud connectivity, I would be very mad if they do.
This risk is why I didn't go with UniFi cameras for example.
Worse comes to worst I would only need to replace the switch and APs, it's a measured risk.&lt;/p&gt;
&lt;h2 id="vlan-design-and-trust-model"&gt;VLAN design and trust model&lt;/h2&gt;
&lt;p&gt;The network will be divided into &lt;strong&gt;trust zones&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Internal (Trusted)&lt;/strong&gt;: Family computers, smartphones and tablets.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IoT&lt;/strong&gt;: Smart home devices (sockets, switches, appliances, TV, air conditioners...).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cameras&lt;/strong&gt;: Security cameras and the doorbell.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Guest&lt;/strong&gt;: For visitors; Internet only, no access to LAN/IoT/Cameras.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Management&lt;/strong&gt;: Personal computer, Proxmox, switches, router, APs, out‑of‑band management (IPMI, iDRAC).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;High‑level routing/firewall rules:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Internal&lt;/strong&gt;: Can access services: Home Assistant, Frigate, NAS shares, etc..&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;IoT&lt;/strong&gt;: Connection to Home Assistant; no direct Internet access, except for a handful of whitelisted endpoints if absolutely required; no access to personal data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cameras&lt;/strong&gt;: Connection to Frigate; no Internet access at all, don't want my camera data exfiltrated; no access to personal data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Guests&lt;/strong&gt;: Internet access only; no access to any internal services.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Management&lt;/strong&gt;: Only reachable from a small set of trusted admin devices.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Firewall and routing rules will be enforced jointly by IPFire and the UniFi switch, ensuring that &lt;strong&gt;camera and IoT traffic never reaches the Internet&lt;/strong&gt;, and guests never reach anything internal.&lt;/p&gt;
&lt;h2 id="wireguard-vpn"&gt;WireGuard VPN&lt;/h2&gt;
&lt;p&gt;For secure external access I will set up &lt;a href="https://www.wireguard.com/"&gt;Wireguard&lt;/a&gt; on IPFire:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Each device (laptop, phone, tablet) gets its own public/private key pair and virtual IP.&lt;/li&gt;
&lt;li&gt;WireGuard peers land in a &lt;em&gt;remote access&lt;/em&gt; subnet that has controlled access to LAN and management.&lt;/li&gt;
&lt;li&gt;This will avoid opening internal services to the Internet, instead providing a single, auditable path: WireGuard → IPFire → internal services.&lt;/li&gt;
&lt;li&gt;WireGuard is limited in features, but because of that its attack surface is much smaller than other VPNs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This should allow full remote control of Home Assistant, camera dashboards, and administration tasks without exposing those services directly to the Internet.&lt;/p&gt;
&lt;h1 id="3-application-server"&gt;3. Application Server&lt;/h1&gt;
&lt;p&gt;The core of the homelab will run on &lt;a href="https://proxmox.com/en/"&gt;Proxmox VE&lt;/a&gt;, installed on a single, server‑grade box with proper remote management and redundancy.&lt;/p&gt;
&lt;p&gt;Proxmox is a secure and robust Linux-based OS that simplifies running virtualized or containerized services.
This provides a number of benefits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Open-source&lt;/strong&gt;: It may seem obvious, but it avoids vendor lock-in.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mix VMs and containers&lt;/strong&gt;: Both can be managed through the same interface.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security through isolation&lt;/strong&gt;: If a service gets compromised, the attacker would not gain access to the whole system, but only to the data and network it has access to.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Portability and testing&lt;/strong&gt;: I could have a staging Proxmox instance, for example on my desktop computer, as a testbed for new services. When they are ready they can be migrated to the production Proxmox instance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Safer upgrades&lt;/strong&gt;: Snapshotting VMs is an easy way of testing a software upgrade and rolling back if issues are found.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backups&lt;/strong&gt;: Besides ZFS snapshots, Proxmox has its own open-source &lt;a href="https://proxmox.com/en/products/proxmox-backup-server/overview"&gt;backup server&lt;/a&gt;, for incremental deduplicated backups.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Availability and clustering&lt;/strong&gt;: I won't use it myself, because I feel it's overkill for my use case, but it's possible to have redundancy and automatic failover of Proxmox instances.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="proxmox-and-licensing-considerations"&gt;Proxmox and licensing considerations&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://proxmox.com/en/products/proxmox-virtual-environment/pricing"&gt;Proxmox is licensed &lt;strong&gt;per CPU socket&lt;/strong&gt;&lt;/a&gt;.
In order to reduce ongoing costs I'm looking for single-CPU servers, but with enough cores to run all planned services, and have room to grow into the future.&lt;/p&gt;
&lt;p&gt;Modern single‑socket server CPUs can have hundreds of cores, which is more than enough for a homelab.&lt;/p&gt;
&lt;h2 id="server-hardware-options"&gt;Server hardware options&lt;/h2&gt;
&lt;p&gt;The target form factor is a refurbished rack server, with these characteristics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Power-efficient CPU with at least 24 cores, ideally 32 or more.&lt;/li&gt;
&lt;li&gt;At least 128 GB of ECC RAM. ECC is critical here to avoid silent data corruption. DDR5 RAM would be ideal, but with the &lt;a href="https://en.wikipedia.org/wiki/2024%E2%80%932026_global_memory_supply_shortage"&gt;ongoing memory shortage&lt;/a&gt; I will settle with DDR4.&lt;/li&gt;
&lt;li&gt;Space for at least 8 HDDs, ideally 10 or 12 to have room to grow if needed.&lt;/li&gt;
&lt;li&gt;Support for dual NVMe storage, or at the very least SSDs.&lt;/li&gt;
&lt;li&gt;At least one 10 GbE SFP+ NIC.&lt;/li&gt;
&lt;li&gt;Dual redundant PSUs.&lt;/li&gt;
&lt;li&gt;IPMI-compatible remote management.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The Dell PowerEdge series has a wide range of models and are easy to find refurbished, so they set my baseline:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Intel‑based&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://i.dell.com/sites/doccontent/shared-content/data-sheets/en/Documents/Dell-PowerEdge-R730-Spec-Sheet.pdf"&gt;R730&lt;/a&gt;&lt;/strong&gt;: Old but very common and cheap. Dual‑socket Intel Xeon, up to 3 TB DDR4, 10 GbE through optional NDC (Network Daughter Card), &lt;strong&gt;no NVMe&lt;/strong&gt;, iDRAC enterprise‑grade management.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href="https://www.dell.com/content/dam/digitalassets/active/en/unauth/data-sheets/products/servers/Dell_EMC_PowerEdge_R740_Spec_Sheet.pdf"&gt;R740&lt;/a&gt; / &lt;a href="https://www.delltechnologies.com/asset/en-us/products/servers/technical-support/dell-emc-poweredge-r750-spec-sheet.pdf"&gt;R750&lt;/a&gt;&lt;/strong&gt;: Newer generations with more efficient CPUs, more cores, NVMe, better PCIe (Gen 3/4 depending on model), better iDRAC...&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;AMD‑based&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="https://i.dell.com/sites/csdocuments/Shared-Content_data-Sheets_Documents/en/poweredge-r7415-spec-sheet-en.pdf"&gt;&lt;strong&gt;R7415&lt;/strong&gt;&lt;/a&gt;: &lt;strong&gt;Single-socket AMD EPYC (1st generation)&lt;/strong&gt;; excellent core density and memory bandwidth at lower power and cost than equivalent Intel parts, and designed as single‑socket platforms.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://i.dell.com/sites/csdocuments/product_docs/en/poweredge-r7515-spec-sheet.pdf"&gt;&lt;strong&gt;R7515&lt;/strong&gt;&lt;/a&gt;: Single-socket &lt;strong&gt;2nd and 3rd generation AMD EPYC&lt;/strong&gt;, up to 64 cores.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;R7525&lt;/strong&gt;: Like R7515 but with dual socket. Not a deal-breaker because Proxmox only charges per CPU socket &lt;strong&gt;in use&lt;/strong&gt;, so it can be an option if priced fairly.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The &lt;strong&gt;single‑socket AMD EPYC&lt;/strong&gt; platforms seem particularly attractive for Proxmox because:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;They deliver higher core counts than Intel and more memory bandwidth in a single socket, avoiding per‑socket licensing overhead.&lt;/li&gt;
&lt;li&gt;EPYC platforms are known for strong virtualization performance and high I/O density.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/self_hosted_home_1/dell_poweredge_r7515.webp" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/self_hosted_home_1/dell_poweredge_r7515.webp" title="Click for full screen"&gt;&lt;figcaption&gt;Dell PowerEdge R7515&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;A Dell PowerEdge R7415 or R7515 looks like the best fit, but I will compare to similar offers from other vendors:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.supermicro.com/en/products/system/datasheet/as-2014s-tr"&gt;Supermicro AS-2014S-TR&lt;/a&gt;: mostly similar, but it has more PCIe lanes and more drive bay with space for 12 HDDs, so it would allow for expansion of the NAS if necessary.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.hpe.com/psnow/doc/a00073549enw"&gt;HPE ProLiant DL385 Gen10 Plus&lt;/a&gt;: Dual socket; up to 28 drives. Overkill.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lenovopress.lenovo.com/lp1269-thinksystem-sr665-server"&gt;Lenovo ThinkSystem SR665&lt;/a&gt;: Supports single-socket AMD EPYC CPUs with the "P" suffix. They are reportedly hard to find refurbished, single-socket even more so, so it's not my first option.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One thing that makes me reluctant to go HPE is that they don't offer free downloads of BIOS or firmware upgrades without a support contract, which is prohibitively expensive for a home user.
I was already bitten by that with my current HPE Microserver Gen10, so I will probably go with Dell or Supermicro for the next one, as they have free downloads without a contract.&lt;/p&gt;
&lt;p&gt;My final choice will depend on local availability and pricing of refurbished units, but I'm hoping to nail one of those sweet Dell PowerEdge with an AMD EPYC.&lt;/p&gt;
&lt;h2 id="redundancy-and-reliability"&gt;Redundancy and reliability&lt;/h2&gt;
&lt;p&gt;The Proxmox server is treated as the &lt;strong&gt;primary infrastructure node&lt;/strong&gt;, it's not a toy.
While my smart home is designed so that the house will keep working even if Home Assistant goes down, given that all my personal services will be running on this hardware means it is of utmost importance that it stays up and healthy.&lt;/p&gt;
&lt;p&gt;With this in mind, these are the steps I will be taking to guarantee the availability of the application server:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dual PSUs&lt;/strong&gt;: Connected to separate outlets, so a single PSU or electrical circuit failure does not bring the server down.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UPS&lt;/strong&gt;: Connected to at least one UPS, for emergency power backup and graceful shutdown. More details in the &lt;em&gt;Power and Resilience&lt;/em&gt; section below.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Boot / VM pool&lt;/strong&gt;: &lt;strong&gt;2x NVMe drives&lt;/strong&gt; in a mirrored ZFS pool (often described loosely as "RAID1 on ZFS"). This provides redundancy for the Proxmox host, services, and configuration, so if one drive fails the system will keep on going (and the faulty drive could be hot-swapped with a new one and ZFS would rebuild the pool online).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Dual network ports&lt;/strong&gt;: Use at least two NICs: one for management; one for VM traffic and storage. I could also implement failover for the network links by using interface bonding, that would mean 2x for management, 2x for traffic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Remote management&lt;/strong&gt;: &lt;a href="https://en.wikipedia.org/wiki/Intelligent_Platform_Management_Interface"&gt;IPMI&lt;/a&gt;, iDRAC, iLO, or equivalent to allow out‑of‑band management, BIOS access, and remote power control.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Physical console for emergency access&lt;/strong&gt;: I will have a cheap console (monitor and keyboard) around to directly connect to the server if necessary.&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="4-storage"&gt;4. Storage&lt;/h1&gt;
&lt;p&gt;I have large storage needs, so I know from the get go that I want a &lt;a href="https://en.wikipedia.org/wiki/Network-attached_storage"&gt;NAS&lt;/a&gt;, with good speed, redundancy and reliability.&lt;/p&gt;
&lt;h2 id="why-not-synology"&gt;Why not Synology?&lt;/h2&gt;
&lt;p&gt;I initially considered a &lt;a href="https://www.synology.com/en-us"&gt;Synology&lt;/a&gt; NAS.
Particularly the &lt;a href="https://www.synology.com/en-eu/products/RS1221+"&gt;RS1221+&lt;/a&gt; seemed to be exactly what I was looking for.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/self_hosted_home_1/synology_rs1221+.webp" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/self_hosted_home_1/synology_rs1221+.webp" title="Click for full screen"&gt;&lt;figcaption&gt;Synology RS1221+&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Synology products are popular for good reason: they are easy to manage and have a rich ecosystem.
However, recent policy and business decisions have made them less attractive, and particularly deal breakers for me:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Increasingly &lt;a href="https://www.theverge.com/news/652364/synology-nas-third-party-hard-drive-restrictions"&gt;restrictive stance on third‑party drives&lt;/a&gt;, with models that:&lt;ul&gt;
&lt;li&gt;Warn, complain, or degrade the UX when non‑Synology drives are used.&lt;/li&gt;
&lt;li&gt;In some cases, limit support or functionality based on drive vendor.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;A growing "appliance lock‑in" direction that goes against the &lt;strong&gt;open, standards‑first&lt;/strong&gt; philosophy of this homelab.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Given these concerns, I made the decision to &lt;strong&gt;avoid Synology&lt;/strong&gt; and instead use open platforms and commodity hardware where I would be in full control.&lt;/p&gt;
&lt;h2 id="enter-truenas"&gt;Enter TrueNAS&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://www.truenas.com/"&gt;TrueNAS&lt;/a&gt; is probably the best known open-source OS specialized as a NAS.
It's robust, which is the number one feature you want for your storage.&lt;/p&gt;
&lt;p&gt;It only works with ZFS, which is what I want to use for my data layer, and its UI simplifies management, monitoring and alerts.
As much as I like technology and getting my hands dirty I will be busy setting everything up so I will take ease of setup and maintenance whenever I can, without losing control of course.&lt;/p&gt;
&lt;p&gt;I considered &lt;a href="https://www.openmediavault.org/"&gt;openmediavault (OMV)&lt;/a&gt; because it's a direct competitor, also open-source and Linux-based.
I discarded it because it is more flexible (for example ZFS is optional), it is meant for people looking to run apps in their NAS, but I don't need to because I will have a separate app server for that purpose.&lt;/p&gt;
&lt;p&gt;So I made plans for setting up a separate server for TrueNAS, which given my storage requirements would need another beefy box (at least 64 GB of RAM to begin with, and another 10 GbE NIC).
Like everything in life, it's got pros and cons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Pros:&lt;ul&gt;
&lt;li&gt;Clear separation of concerns: storage on one box, compute on another.&lt;/li&gt;
&lt;li&gt;Good fault isolation: if the compute node fails, storage remains up.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Cons:&lt;ul&gt;
&lt;li&gt;Higher &lt;strong&gt;upfront cost&lt;/strong&gt;: another server chassis, more PSUs, more NICs.&lt;/li&gt;
&lt;li&gt;Higher &lt;strong&gt;ongoing power usage&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;For a family homelab, this can be overkill.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There were two alternatives that I had read about:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Proxmox has ZFS support, so it could double as the NAS.&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Manual ZFS&lt;/strong&gt; configuration, one more thing to learn.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No out-of-the-box&lt;/strong&gt; monitoring or alerting.&lt;/li&gt;
&lt;li&gt;Direct hardware access, &lt;strong&gt;no virtualization performance loss&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Run a &lt;strong&gt;TrueNAS VM&lt;/strong&gt; inside Proxmox:&lt;ul&gt;
&lt;li&gt;TrueNAS runs as a VM with &lt;strong&gt;direct access to the HDDs&lt;/strong&gt; via passthrough of the PCIe &lt;a href="https://en.wikipedia.org/wiki/Host_adapter"&gt;HBA&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;The hard drives are &lt;strong&gt;dedicated to TrueNAS&lt;/strong&gt;; Proxmox does not touch them.&lt;/li&gt;
&lt;li&gt;ZFS is used at the TrueNAS layer for redundancy, snapshots, and replication.&lt;/li&gt;
&lt;li&gt;Performance hit is &amp;lt;5% according to some user reports.&lt;/li&gt;
&lt;li&gt;TrueNAS will enjoy the &lt;strong&gt;high-availability&lt;/strong&gt; of the application server (dual PSUs, dual NVMe, redundant networking...)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;After a pros &amp;amp; cons analysis I'm decided to go with the TrueNAS VM as the best compromise for maintainability, reduced hardware count, power draw, upfront cost and reliability.
Having Proxmox handle the HDDs might work, but I would lose more time and lose on TrueNAS built-in monitoring and alerts.&lt;/p&gt;
&lt;h2 id="storage-layout"&gt;Storage layout&lt;/h2&gt;
&lt;p&gt;At a high level:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;IPFire (fast)&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;Network logs (firewall hits, IDS reports, VPN connections, etc.).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Proxmox pool (very fast, redundant)&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;Two NVMe SSDs in ZFS mirror for:&lt;ul&gt;
&lt;li&gt;Proxmox host.&lt;/li&gt;
&lt;li&gt;VMs and containers.&lt;/li&gt;
&lt;li&gt;Configuration, databases and metadata.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;System will continue working if one drive fails.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TrueNAS pool (slow, redundant)&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;Multiple HDDs in a ZFS pool.&lt;/li&gt;
&lt;li&gt;I'm planning initially for 8 HDDs in RAIDZ2, ideally with room for expansion up to 12.&lt;/li&gt;
&lt;li&gt;Fault tolerant up to 2 drives simultaneously.&lt;/li&gt;
&lt;li&gt;Stores:&lt;ul&gt;
&lt;li&gt;Immich's photo library.&lt;/li&gt;
&lt;li&gt;Jellyfin media.&lt;/li&gt;
&lt;li&gt;Music Assistant music.&lt;/li&gt;
&lt;li&gt;Nextcloud data.&lt;/li&gt;
&lt;li&gt;Paperless documents.&lt;/li&gt;
&lt;li&gt;Camera recordings.&lt;/li&gt;
&lt;li&gt;Proxmox backup.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Snapshots and replication&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;TrueNAS will &lt;strong&gt;periodically snapshot datasets&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Critical data snapshots (Nextcloud, Immich, Paperless, some Frigate recordings) will be replicated to a &lt;strong&gt;remote backup&lt;/strong&gt; (off‑site).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This combination provides both &lt;strong&gt;local redundancy&lt;/strong&gt; and &lt;strong&gt;off‑site backups&lt;/strong&gt;, which is essential: RAID/ZFS is &lt;em&gt;not&lt;/em&gt; a backup.&lt;/p&gt;
&lt;h1 id="5-cameras-and-doorbell"&gt;5. Cameras and Doorbell&lt;/h1&gt;
&lt;p&gt;Cameras are a key part of this homelab, but also a major &lt;strong&gt;security and privacy concern&lt;/strong&gt;.
I want to have cameras both outside and inside the house, but I have to make absolutely certain that &lt;strong&gt;nobody is snooping&lt;/strong&gt; through them.
I also want to have a video doorbell that will ring on my smartphone even if I'm away from home, but it has to work without cloud connectivity.&lt;/p&gt;
&lt;p&gt;I will first have an installer run in-wall Ethernet cables for the cameras, so they will be connected and powered through PoE.
There is a wide range of PoE cameras and doorbells available.&lt;/p&gt;
&lt;h2 id="initial-plan-unifi-or-reolink"&gt;Initial plan: UniFi or Reolink?&lt;/h2&gt;
&lt;p&gt;At first I was debating whether to go with UniFi cameras, because they are good quality (if pricey), their software and apps are best-in-class and I am already planning on getting into their ecosystem with the switch and APs.
However Ubiquiti has tried to &lt;a href="https://www.reddit.com/r/Ubiquiti/comments/1cifnut/unifi_protect_now_requires_cloudremote_access_for/"&gt;require a cloud account for UniFi Protect&lt;/a&gt; in the past, so I cannot trust that their systems will continue to work in a local-only fashion, which is one of my stated goals.&lt;/p&gt;
&lt;p&gt;Having discarded UniFi cameras, my other option was &lt;a href="https://reolink.com/"&gt;Reolink&lt;/a&gt; for its good quality-price ratio and perfect &lt;a href="https://www.home-assistant.io/integrations/reolink/"&gt;Home Assistant integration&lt;/a&gt;.
The &lt;a href="https://reolink.com/product/rln16-410/"&gt;Reolink RLN16-410&lt;/a&gt; has 16x PoE ports, so cameras would connect and record directly to it.
That would simplify the setup and integrate with Home Assistant.&lt;/p&gt;
&lt;p&gt;However after some research I ended up discovering that Reolink has &lt;strong&gt;no IPv6 support&lt;/strong&gt; at all, and no roadmap to add it.
Complete lack of IPv6 support is especially problematic in a network where IPv6 is a first‑class citizen.
Over time, services and ISPs will rely more on IPv6, and running an IPv4‑only camera ecosystem creates unnecessary friction and technical debt.
Without any kind of promise that IPv6 will be supported in the future I would risk having to replace all cameras (and the NVR!) at some point, so I went looking for alternatives again.&lt;/p&gt;
&lt;h2 id="frigate-and-dahua-cameras"&gt;Frigate and Dahua cameras&lt;/h2&gt;
&lt;p&gt;My final solution is to stop relying on closed systems and instead have the excellent &lt;a href="https://frigate.video/"&gt;Frigate NVR&lt;/a&gt; software managing the cameras directly.
Frigate will run as a VM in the Proxmox server.&lt;/p&gt;
&lt;p&gt;Looking for a camera maker that respects standards and has IPv6 support I found &lt;a href="https://www.dahuasecurity.com/"&gt;Dahua&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Standards support&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;ONVIF for discovery and basic control.&lt;/li&gt;
&lt;li&gt;RTSP for video and audio streams.&lt;/li&gt;
&lt;li&gt;IPv6 support.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;2‑way audio&lt;/strong&gt; capabilities that work with standards‑compliant tools.&lt;/li&gt;
&lt;li&gt;A broad ecosystem and documentation from the self‑hosting community.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There will be:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;12 Dahua cameras&lt;/strong&gt;, mix of indoor/outdoor.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1 Dahua doorbell&lt;/strong&gt; with 2‑way audio.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All cameras and the doorbell will be connected via &lt;strong&gt;PoE over Cat6a&lt;/strong&gt; cabling, ensuring good signal and also making them harder to tamper and interfere with.&lt;/p&gt;
&lt;p&gt;Frigate will store recordings on TrueNAS (via NFS or SMB), so they automatically benefit from its hard disk redundancy, ZFS snapshots and backup routines.&lt;/p&gt;
&lt;h2 id="network-design-for-cameras"&gt;Network design for cameras&lt;/h2&gt;
&lt;p&gt;All cameras will connect to a dedicated unmanaged PoE-enabled layer-2 Ethernet switch in the rack.
This switch will uplink to a single port of the UniFi switch, that way it can tag all cameras in a &lt;strong&gt;dedicated VLAN&lt;/strong&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Receive addresses only on the &lt;strong&gt;Camera VLAN&lt;/strong&gt; (both IPv4 and IPv6).&lt;/li&gt;
&lt;li&gt;IPFire firewall will &lt;strong&gt;disallow any Internet egress&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Can be accessed only from:&lt;ul&gt;
&lt;li&gt;Frigate (for video ingest and 2-way communication).&lt;/li&gt;
&lt;li&gt;A very small set of admin systems (for configuration).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The main downside of this approach is that the secondary unmanaged switch can only be used for connecting the cameras, because it will be the UniFi router upstream that will handle the VLAN, but it should be cheap enough that having some unused ports won't hurt.&lt;/p&gt;
&lt;h2 id="object-detection"&gt;Object detection&lt;/h2&gt;
&lt;p&gt;Frigate NVR has built‑in object detection:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Frigate runs as a VM (or container) on Proxmox.&lt;/li&gt;
&lt;li&gt;It ingests RTSP streams from the Dahua cameras and doorbell.&lt;/li&gt;
&lt;li&gt;It performs &lt;strong&gt;motion detection and object detection&lt;/strong&gt; (people, cars, etc.).&lt;/li&gt;
&lt;li&gt;It manages event‑based recording, snapshots, and retention policies.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For efficient inference, Frigate uses a &lt;a href="https://docs.frigate.video/configuration/object_detectors/#edge-tpu-detector"&gt;Coral TPU accelerator&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A USB or PCIe Coral TPU will be plugged into the Proxmox server.&lt;/li&gt;
&lt;li&gt;Proxmox will passthrough the device to the Frigate VM.&lt;/li&gt;
&lt;li&gt;As a specialized hardware device, the TPU has low power consumption but is capable of handling multiple video streams in real time.&lt;/li&gt;
&lt;li&gt;It's far more efficient and cost‑effective than running GPU inference for this scale of deployment.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="video-doorbell"&gt;Video doorbell&lt;/h2&gt;
&lt;p&gt;The video doorbell can be viewed as a normal camera, but I want to be able to talk with whoever calls.
Frigate can &lt;a href="https://docs.frigate.video/configuration/live/#two-way-talk"&gt;handle 2‑way audio&lt;/a&gt; with the Dahua doorbell, enabling local‑only, privacy‑preserving communication without any vendor cloud.&lt;/p&gt;
&lt;p&gt;Remember that all cameras will be accessible only through the local network, but my devices will connect through the Wireguard VPN as if they are at home.&lt;/p&gt;
&lt;p&gt;When somebody rings the bell that will trigger an event in Home Assistant, and I will be able to do any number of automations, from sending a notification to our phones, playing a chime in the house if we are present, playing a recorded message or even having an AI chat with the visitor.&lt;/p&gt;
&lt;h1 id="6-power-and-resilience"&gt;6. Power and Resilience&lt;/h1&gt;
&lt;p&gt;Power planning has two separate parts:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Reducing power consumption, both during normal day-to-day and in an emergency (e.g. a blackout).&lt;/li&gt;
&lt;li&gt;Securing data and graceful degradation of services in case of power loss.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="on-power-consumption"&gt;On power consumption&lt;/h2&gt;
&lt;p&gt;The house will have a full solar installation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Plenty of solar panels that should cover the house most days of the year.&lt;/li&gt;
&lt;li&gt;Enough batteries to cover the house most nights of the year.&lt;/li&gt;
&lt;li&gt;The grid will probably be used only in cloudy winter days, it's not like I'm planning to be completely independent.&lt;/li&gt;
&lt;li&gt;An off-grid emergency circuit will allow the batteries and panels to provide electricity in case of a grid blackout.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;With this system in place, the rack should be properly powered under most situations.
However I can imagine some cases where it might come up short:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Multiple overcast days in Winter could drain the batteries fully. I will not know the performance for sure until we move in and I start collecting stats. This means I cannot rely on just the solar batteries to power the rack.&lt;/li&gt;
&lt;li&gt;A prolonged grid blackout (like the &lt;a href="https://en.wikipedia.org/wiki/2025_Iberian_Peninsula_blackout"&gt;2025 one&lt;/a&gt;) could be problematic, especially in Winter.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As protection against these edge cases my plan is to install an UPS in the rack, and program the services for the eventuality.&lt;/p&gt;
&lt;h2 id="power-emergency-ups-and-shutdown-strategy"&gt;Power emergency: UPS and shutdown strategy&lt;/h2&gt;
&lt;p&gt;Critical components will be connected to an &lt;a href="https://en.wikipedia.org/wiki/Uninterruptible_power_supply"&gt;UPS&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IPFire router.&lt;/li&gt;
&lt;li&gt;Main UniFi switch.&lt;/li&gt;
&lt;li&gt;PoE camera switch.&lt;/li&gt;
&lt;li&gt;Proxmox server.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I have two goals:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Keep &lt;strong&gt;network + cameras + core services&lt;/strong&gt; running for as long as possible during an outage.&lt;/li&gt;
&lt;li&gt;Ensure the &lt;strong&gt;Proxmox server shuts down cleanly&lt;/strong&gt; before the UPS battery is depleted, to keep data safe.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The shutdown flow would look like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;UPS communicates via USB or SNMP to a monitoring agent (either on Proxmox or a small sidecar device).&lt;/li&gt;
&lt;li&gt;On UPS battery activation (and the same if there is a grid blackout and the system relies on the solar batteries) trigger an energy save mode across all systems and notify me.&lt;/li&gt;
&lt;li&gt;On low battery threshold:&lt;ul&gt;
&lt;li&gt;Proxmox initiates graceful shutdown of non-critical VMs in order of priority (e.g. Jellyfin, Immich and Nextcloud first).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;On critical battery threshold:&lt;ul&gt;
&lt;li&gt;Proxmox shuts down all VMs to avoid data loss.&lt;/li&gt;
&lt;li&gt;Proxmox host shuts down.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Networking and cameras remain up as long as the UPS can sustain them.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This protects data integrity while maintaining some level of surveillance and remote troubleshooting capability.&lt;/p&gt;
&lt;h1 id="7-backups-and-recovery"&gt;7. Backups and Recovery&lt;/h1&gt;
&lt;p&gt;Redundancy is not backup.
There has to be an off-site copy of the critical data in case catastrophe strikes the home:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Regular backups of &lt;strong&gt;Proxmox VMs&lt;/strong&gt; (configuration + disk images) to the NAS.&lt;/li&gt;
&lt;li&gt;Replication of the Proxmox backups to an off-site location.&lt;/li&gt;
&lt;li&gt;Backup of &lt;strong&gt;critical datasets&lt;/strong&gt; from the NAS to the off-site location:&lt;ul&gt;
&lt;li&gt;Photo collection (Immich), with metadata database.&lt;/li&gt;
&lt;li&gt;Nextcloud files and data (calendar, contacts...).&lt;/li&gt;
&lt;li&gt;Paperless documents.&lt;/li&gt;
&lt;li&gt;Frigate event clips (at least metadata and critical recordings, e.g. object detections or alerts).&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Periodic &lt;strong&gt;restore tests&lt;/strong&gt; will be part of the operational model: a backup is only as good as the last successful restore test.
The plan is to test restoring at least one VM and one dataset periodically to ensure the tooling and procedures actually work.&lt;/p&gt;
&lt;h1 id="8-operations-monitoring-and-security-practices"&gt;8. Operations, Monitoring, and Security Practices&lt;/h1&gt;
&lt;p&gt;Maintainability is a key concern for my systems.
I really enjoy tinkering with all this stuff, it's a hobby really, but once setup I expect it to run hassle-free for months.
That is the experience with &lt;a href="https://blog.fidelramos.net/software/homelab"&gt;my current homelab&lt;/a&gt;, and what I want out of the new one.&lt;/p&gt;
&lt;p&gt;The operational practices I'm planning to keep the system maintainable:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Monitoring&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;Basic uptime checks for core services, for example with &lt;a href="https://uptime.kuma.pet/"&gt;Uptime Kuma&lt;/a&gt;, with real-time notifications.&lt;/li&gt;
&lt;li&gt;Resource monitoring for CPU/RAM/disk/network, with threshold alerts to be aware of bottlenecks.&lt;/li&gt;
&lt;li&gt;SMART and ZFS scrub alerts for storage.&lt;/li&gt;
&lt;li&gt;IPFire intrusion alerts.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Update strategy&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;Apply updates in a controlled fashion:&lt;ul&gt;
&lt;li&gt;Stable versions only.&lt;/li&gt;
&lt;li&gt;Take Proxmox and VM snapshots before critical upgrades.&lt;/li&gt;
&lt;li&gt;Update less critical services first, then core components.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security&lt;/strong&gt;:&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Deny-by-default&lt;/strong&gt; firewall.&lt;/li&gt;
&lt;li&gt;IoT and Cameras VLANs with &lt;strong&gt;no Internet egress&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;As few port‑forwarded services exposed to the Internet as possible, and those with &lt;strong&gt;minimal permissions and data access&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;VPN only&lt;/strong&gt; for remote access to Home Assistant, Frigate and any other house services which are not be exposed to the Internet.&lt;/li&gt;
&lt;li&gt;Minimal attack surface on IPFire and Proxmox:&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Only necessary ports&lt;/strong&gt; open.&lt;/li&gt;
&lt;li&gt;Admin only through &lt;strong&gt;trusted devices&lt;/strong&gt; (desktop and laptop computers).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Strong keys&lt;/strong&gt; and passwords.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MFA&lt;/strong&gt; where supported.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Up-to-date&lt;/strong&gt; software.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h1 id="9-next-steps"&gt;9. Next Steps&lt;/h1&gt;
&lt;p&gt;This post captures the &lt;strong&gt;target design&lt;/strong&gt; I arrived at after months of research.
The implementation will proceed in stages, which will no doubt end up overlapping:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Procure router/firewall server&lt;/strong&gt;, install IPFire and start the base routing/firewall setup.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Procure application server&lt;/strong&gt; and install Proxmox VE.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Procure and install rack, patch panels, and switches&lt;/strong&gt; once the dedicated server room at the house is ready.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Install servers and connect everything&lt;/strong&gt;; connect all network cables; connect devices to UPS for power; start servers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Procure and install HDDs&lt;/strong&gt; in the application server; set up the &lt;strong&gt;TrueNAS VM&lt;/strong&gt; on Proxmox.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Set up monitoring, logging, alerts&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bring up Home Assistant&lt;/strong&gt;, integrate core smart devices, and start defining automations.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deploy Frigate&lt;/strong&gt; and integrate all Dahua cameras and the doorbell.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deploy Nextcloud, Immich, Jellyfin, Paperless&lt;/strong&gt;, pointing them to TrueNAS storage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Supporting services&lt;/strong&gt; (monitoring, logging, alerts, etc.).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Each of these steps will generate its own set of notes, configuration snippets, and "wish I had known this earlier" insights.
This is why this post is labeled as "Part 1", because I intend to fully document how this dream homelab evolves from design to reality.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/pages/subscribe.html"&gt;Subcribe&lt;/a&gt; for updates!&lt;/p&gt;
&lt;p&gt;Got any ideas, suggestions or feedback? Leave me a comment below!&lt;/p&gt;</content><category term="Software"/><category term="free-software"/><category term="linux"/><category term="self-hosting"/></entry><entry><title>Periodic script to back up installed OS packages</title><link href="https://blog.fidelramos.net/software/backup-installed-packages" rel="alternate"/><published>2025-12-08T00:00:00+00:00</published><updated>2025-12-08T10:55:41.127330+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2025-12-08:/software/backup-installed-packages</id><summary type="html">&lt;p&gt;A while ago I &lt;a href="https://github.com/haplo/dotfiles/commit/0f7aedfa2ee71ca4a5d709d2f2a21c57a83be264"&gt;switched to yadm&lt;/a&gt; for managing my dotfiles.
One of its features I have been enjoying is its &lt;a href="https://yadm.io/docs/bootstrap"&gt;bootstrap support&lt;/a&gt;.
I have been adding idempotent setup operations&lt;/p&gt;
&lt;p&gt;My latest addition is a script that creates backup files with all installed OS packages.
In case of disaster if …&lt;/p&gt;</summary><content type="html">&lt;p&gt;A while ago I &lt;a href="https://github.com/haplo/dotfiles/commit/0f7aedfa2ee71ca4a5d709d2f2a21c57a83be264"&gt;switched to yadm&lt;/a&gt; for managing my dotfiles.
One of its features I have been enjoying is its &lt;a href="https://yadm.io/docs/bootstrap"&gt;bootstrap support&lt;/a&gt;.
I have been adding idempotent setup operations&lt;/p&gt;
&lt;p&gt;My latest addition is a script that creates backup files with all installed OS packages.
In case of disaster if I have to reinstall the OS I can just feed the package manager the latest backup list for that host.
I'm working on a different set up for my data backup, that will be the subject of a future post.&lt;/p&gt;
&lt;p&gt;On to the script, it is simple and tailored for my systems.
It only supports APT (I use Debian for my headless servers) and pacman (I use Arch Linux on my desktop), but adding DNF or other package managers should be easy enough.
I wrote it in fish shell, because it is &lt;a href="https://blog.fidelramos.net/software/fish-shell"&gt;my preferred shell now&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You can see the &lt;a href="https://github.com/haplo/dotfiles/commit/3d9c8e0f545a9f7636148cce050dbd88c447ea29"&gt;commit in my dotfiles&lt;/a&gt; for the full changes, but I will be reproducing them below as of the time of writing this article.&lt;/p&gt;
&lt;p&gt;The main script I put in &lt;em&gt;~/.local/bin/backup_installed_packages_list.fish&lt;/em&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env fish&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; get_hostname
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt; -q hostname
        hostname
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt; -q hostnamectl
        hostnamectl --static
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;test&lt;/span&gt; -f /etc/hostname
        &lt;span class="nb"&gt;read&lt;/span&gt; -l host_from_file &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; /etc/hostname
        &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$host_from_file&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; string trim
    &lt;span class="k"&gt;else&lt;/span&gt;
        &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Error: cannot reliably get hostname"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;2
        &lt;span class="k"&gt;exit&lt;/span&gt; 2
    &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

&lt;span class="k"&gt;set&lt;/span&gt; current_hostname &lt;span class="o"&gt;(&lt;/span&gt;get_hostname&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; current_date &lt;span class="o"&gt;(&lt;/span&gt;date +%Y-%m-%d&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; backup_dir &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/Backups/&lt;/span&gt;&lt;span class="nv"&gt;$current_hostname&lt;/span&gt;&lt;span class="s2"&gt;/packages"&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; temp_file &lt;span class="o"&gt;(&lt;/span&gt;mktemp&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;function&lt;/span&gt; save_if_changed -a input_file -a suffix
    &lt;span class="k"&gt;set&lt;/span&gt; -l target_file &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$backup_dir&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$current_date$suffix&lt;/span&gt;&lt;span class="s2"&gt;.txt"&lt;/span&gt;

    &lt;span class="c"&gt;# regex matches YYYY-MM-DD followed optionally by suffix, ending in .txt&lt;/span&gt;
    &lt;span class="k"&gt;set&lt;/span&gt; -l pattern
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;test&lt;/span&gt; -z &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$suffix&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;set&lt;/span&gt; pattern &lt;span class="s1"&gt;'^\d{4}-\d{2}-\d{2}\.txt$'&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
        &lt;span class="k"&gt;set&lt;/span&gt; pattern &lt;span class="s2"&gt;"^\d{4}-\d{2}-\d{2}&lt;/span&gt;&lt;span class="nv"&gt;$suffix&lt;/span&gt;&lt;span class="s2"&gt;\.txt\$"&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="c"&gt;# find the most recent backup file that matches the specific pattern&lt;/span&gt;
    &lt;span class="k"&gt;set&lt;/span&gt; -l latest_backup_name &lt;span class="o"&gt;(&lt;/span&gt;ls -1 &lt;span class="nv"&gt;$backup_dir&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;|&lt;/span&gt; string match -r &lt;span class="nv"&gt;$pattern&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; sort &lt;span class="o"&gt;|&lt;/span&gt; tail -n 1&lt;span class="o"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;test&lt;/span&gt; -n &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$latest_backup_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;set&lt;/span&gt; -l latest_backup_path &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$backup_dir&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$latest_backup_name&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; cmp -s &lt;span class="nv"&gt;$input_file&lt;/span&gt; &lt;span class="nv"&gt;$latest_backup_path&lt;/span&gt;
            &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No changes in package list compared to &lt;/span&gt;&lt;span class="nv"&gt;$latest_backup_path&lt;/span&gt;&lt;span class="s2"&gt;. Skipping."&lt;/span&gt;
            rm &lt;span class="nv"&gt;$input_file&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;

    &lt;span class="c"&gt;# if we reach here, either no backup exists or content is different&lt;/span&gt;
    mv &lt;span class="nv"&gt;$input_file&lt;/span&gt; &lt;span class="nv"&gt;$target_file&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Installed packages backup saved: &lt;/span&gt;&lt;span class="nv"&gt;$target_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    notify-send &lt;span class="se"&gt;\&lt;/span&gt;
        --urgency&lt;span class="o"&gt;=&lt;/span&gt;normal &lt;span class="se"&gt;\&lt;/span&gt;
        --wait &lt;span class="se"&gt;\&lt;/span&gt;
        --icon&lt;span class="o"&gt;=&lt;/span&gt;backup &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="s2"&gt;"New backup of installed OS packages: &lt;/span&gt;&lt;span class="nv"&gt;$target_file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;

mkdir -p &lt;span class="nv"&gt;$backup_dir&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt; -q apt
    &lt;span class="c"&gt;# https://www.debian.org/doc/manuals/debian-reference/ch10.en.html#_backup_and_recovery_policy&lt;/span&gt;
    dpkg --get-selections &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$temp_file&lt;/span&gt;
    save_if_changed &lt;span class="nv"&gt;$temp_file&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt; -q pacman
    &lt;span class="c"&gt;# https://wiki.archlinux.org/title/Migrate_installation_to_new_hardware#List_of_installed_packages&lt;/span&gt;
    pacman -Qqen &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$temp_file&lt;/span&gt;
    save_if_changed &lt;span class="nv"&gt;$temp_file&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt;
    pacman -Qqem &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nv"&gt;$temp_file&lt;/span&gt;
    save_if_changed &lt;span class="nv"&gt;$temp_file&lt;/span&gt; &lt;span class="s2"&gt;"_aur"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Error: Neither apt nor pacman found."&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;2
    rm &lt;span class="nv"&gt;$temp_file&lt;/span&gt;
    &lt;span class="k"&gt;exit&lt;/span&gt; 1
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;I made the &lt;code&gt;get_hostname&lt;/code&gt; helper because my Arch Linux setup didn't have the &lt;code&gt;hostname&lt;/code&gt; command available, only &lt;code&gt;hostnamectl&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;notify-send&lt;/code&gt; is nice to get desktop notifications, I have been using it frequently in my new scripts, especially those running in the background like this one.
Note that it requires &lt;em&gt;libnotify&lt;/em&gt; to be installed (&lt;em&gt;libnotify-bin&lt;/em&gt; in Debian-based installations).
I install it as a common package in my yadm bootstrap configuration.&lt;/p&gt;
&lt;p&gt;I then define systemd service and timer units, in &lt;em&gt;~/.config/systemd/user/backup-installed-packages.service&lt;/em&gt; and &lt;em&gt;~/.config/systemd/user/backup-installed-packages.timer&lt;/em&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Backup list of installed OS packages&lt;/span&gt;
&lt;span class="na"&gt;After&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;graphical-session.target&lt;/span&gt;

&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;oneshot&lt;/span&gt;
&lt;span class="na"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;%h/.local/bin/backup_installed_packages_list.fish&lt;/span&gt;
&lt;span class="na"&gt;StandardOutput&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;journal&lt;/span&gt;
&lt;span class="na"&gt;StandardError&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;journal&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Timer to backup list of installed OS packages&lt;/span&gt;

&lt;span class="k"&gt;[Timer]&lt;/span&gt;
&lt;span class="na"&gt;OnCalendar&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;daily&lt;/span&gt;
&lt;span class="na"&gt;Persistent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="k"&gt;[Install]&lt;/span&gt;
&lt;span class="na"&gt;WantedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;timers.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Then just enable the timer:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;systemctl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;reload&lt;/span&gt;
&lt;span class="o"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;systemctl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;enable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;backup&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;installed&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;packages&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;I enable the systemd timer in my &lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/yadm/bootstrap"&gt;yadm bootstrap&lt;/a&gt; file.&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="backup"/><category term="fish"/><category term="free-software"/><category term="howto"/><category term="linux"/><category term="shell"/></entry><entry><title>Responsible education in computing</title><link href="https://blog.fidelramos.net/software/computing-education-1" rel="alternate"/><published>2025-11-09T00:00:00+00:00</published><updated>2025-11-10T10:17:52.962960+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2025-11-09:/software/computing-education-1</id><summary type="html">&lt;p&gt;My oldest daughter is turning 7 soon, and I have promised her a gift: her first computer.
I'm secretly hoping that she will develop an interest in computing similar to my own.
Not because I want to reproduce my childhood in her (although, of course, I would like to share …&lt;/p&gt;</summary><content type="html">&lt;p&gt;My oldest daughter is turning 7 soon, and I have promised her a gift: her first computer.
I'm secretly hoping that she will develop an interest in computing similar to my own.
Not because I want to reproduce my childhood in her (although, of course, I would like to share some of my hobbies and passions, like any father would), but because I believe it is important to be educated in how computers and software works—this is digital literacy.&lt;/p&gt;
&lt;p&gt;Think about how digital our lives are nowadays.
Government, banking, shopping, gaming, socializing, work, movies, shows, videos, music, dating—it all happens increasingly online.
&lt;a href="https://en.wikipedia.org/wiki/Digital_privacy"&gt;Digital privacy&lt;/a&gt; can be as important as physical privacy, and you should &lt;a href="https://digitalprivacy.ieee.org/publications/topics/what-is-digital-privacy-and-its-importance/"&gt;care about it&lt;/a&gt;, but that is difficult if you don't understand how these systems work, or what alternatives exist.
Then there are online threats like scams, phishing, viruses, trojans, malware, adware, spyware, ransomware, and security vulnerabilities like zero-day exploits and rootkits. There's also identity theft, social engineering, data breaches, and the need for tools like password managers, firewalls, and encryption.&lt;/p&gt;
&lt;p&gt;Computers were the future when I was young, but for my kids, they are the present.
What will their future look like?
My crystal ball is under repair, but I can identify some key technologies likely to shape our societies:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;AI in its different forms&lt;/li&gt;
&lt;li&gt;Robotics&lt;/li&gt;
&lt;li&gt;Cryptocurrencies&lt;/li&gt;
&lt;li&gt;3D printing&lt;/li&gt;
&lt;li&gt;AR &amp;amp; VR&lt;/li&gt;
&lt;li&gt;Solar/wind power, energy self-reliance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I believe as parents we have to prepare our children for the world they live and will likely live in, not the world we grew up in.
That is why I will research, plan and develop responsible ways to expose my kids to these and other technologies.
I see computing as the base for everything else, just as learning mathematics is important for so many other disciplines, which is why it's my first step in this journey.&lt;/p&gt;
&lt;h1 id="some-personal-history"&gt;Some personal history&lt;/h1&gt;
&lt;p&gt;When I was 4 years old (almost 5) I asked the &lt;a href="https://en.wikipedia.org/wiki/Biblical_Magi#Spanish_and_Hispanic_customs"&gt;Three Wise Men&lt;/a&gt; for a &lt;a href="https://en.wikipedia.org/wiki/Nintendo_Entertainment_System"&gt;Nintendo NES console&lt;/a&gt;, because those videogames looked really cool.
However the wise men had other plans for me, and instead brought me my first computer, a &lt;a href="https://en.wikipedia.org/wiki/ZX_Spectrum#ZX_Spectrum+"&gt;ZX Sinclair Spectrum+&lt;/a&gt;.
I was a bit disappointed at first when I opened my present and it wasn't the game console I'd asked for, but instead a black block with a keyboard of square keys.
My disappointment quickly dissolved into ecstasy as soon as my father set it up on the TV, and the first game loaded.
I was &lt;strong&gt;hooked&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/computing_education/fidel_and_spectrum.jpg" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/computing_education/fidel_and_spectrum.jpg" title="Click for full screen"&gt;&lt;figcaption&gt;Fidel playing on his ZX Spectrum+&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I played every game I could get my hands on.
I can still hear the &lt;a href="https://www.youtube.com/watch?v=MtBoRp_cSxQ"&gt;sound of games loading from the cassette player&lt;/a&gt;.
Some of my favorite games of that era were:
&lt;a href="https://www.youtube.com/watch?v=FCvgGGe71X4"&gt;Manic Miner&lt;/a&gt;, &lt;a href="https://www.youtube.com/watch?v=9Hy29KoBnGY"&gt;Jet Pac&lt;/a&gt;, &lt;a href="https://www.youtube.com/watch?v=-A0F2yCI8eA"&gt;Sacred Armor of Antiriad&lt;/a&gt;, &lt;a href="https://www.youtube.com/watch?v=lzdv_fOhZUg"&gt;Pipe Mania&lt;/a&gt;, &lt;a href="https://www.youtube.com/watch?v=T0_A7WIFX1k"&gt;Gaunglet&lt;/a&gt;, &lt;a href="https://www.youtube.com/watch?v=A5hM7Cl2TAI"&gt;Arkanoid&lt;/a&gt;, &lt;a href="https://www.youtube.com/watch?v=gdsVwwmv4N4"&gt;Chuckie Egg&lt;/a&gt; and &lt;a href="https://www.youtube.com/watch?v=nW-0rOIudzU"&gt;Thanathos&lt;/a&gt; to name a few.
It was also a golden age for Spanish videogames, companies such as Opera Soft, Topo Soft and Dinamic made gems that were very popular in Spain and abroad.
I remember playing &lt;a href="https://www.youtube.com/watch?v=pFod1VtzMQ0"&gt;La Abadía del Crimen&lt;/a&gt;, &lt;a href="https://www.youtube.com/watch?v=74HLnyQzuEc"&gt;Abu Simbel Profanation&lt;/a&gt; and &lt;a href="https://www.youtube.com/watch?v=JWkKQXh190I"&gt;Fernando Martín Basket Master&lt;/a&gt; among others.
These games were challenging, far from the easy dopamine hit of modern mobile or pay-to-win games.
They improved my reflexes and concentration, introduced me to the English language and taught me perseverance.&lt;/p&gt;
&lt;p&gt;But in the end the videogames were just a gateway, and the real destination that would shape my life was programming.
My father has always been a science fiction buff.
He'd read so many sci-fi novels that he knew that computers were going to be big in the future.
He’d gotten a couple of books for learning &lt;a href="https://en.wikipedia.org/wiki/BASIC"&gt;BASIC&lt;/a&gt;—the programming language supported by the ZX Spectrum (along with assembly, of course)—along with the computer.
He was planning on learning himself, but as soon as I saw him typing with the keyboard I got interested, he showed me how to do it and then my curiosity did the rest.
I tried copying the sample programs, some of which were many pages long, and it was a revelation seeing that somehow this special text was telling the computer what to do.
Then I tried making some modifications, and marveled at how the behavior changed when I tweaked this or that variable.
I didn't write any complicated programs (I was only 5-8 years old, mind you), but the seed had been planted.&lt;/p&gt;
&lt;p&gt;A few years later, when it was clear that I was into this computer thing for good, my father bought me my first IBM PC: a sensational AMD 286 @12 MHz with 1 MB of RAM, a 5¼-inch floppy disk drive and a 40 MB HDD (at a time when most home computers didn't have an HDD at all!)
This opened a whole new world of possibilities.
I learned MS-DOS, my first operating system.
I learned about &lt;em&gt;AUTOEXEC.BAT&lt;/em&gt; and &lt;em&gt;CONFIG.SYS&lt;/em&gt; and how to tweak them to scratch those precious KB of available RAM to run my games.
I learned to use hex editors to hack saved games for extra money or resources.
Eventually I got myself a copy of Borland Turbo C and a C course, and self-taught me C, and later some x86 assembly.
My 12-year old mind had to struggle a bit with conditionals, loops and especially pointers, but it all clicked.
I could call myself a coder.&lt;/p&gt;
&lt;p&gt;With this background I studied Software Engineering at college, and I was the best student in campus.
I started using Linux in 1999, then exclusively since 2003.
I got a job as a programmer in 2007, right after graduating.
And in 2012 I got a great job in Silicon Valley, easily the biggest jump in my career.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/computing_education/fidel_and_pc.jpg" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/computing_education/fidel_and_pc.jpg" title="Click for full screen"&gt;&lt;figcaption&gt;Fidel on his PC&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;I don't want to end this nostalgia trip without reminiscing on some of the many &lt;em&gt;many&lt;/em&gt; games that I played with my PC.
I'm not going to argue that every game is a piece of art, but I think they are a more positive experience than most adults would credit them for.
There is concern nowadays about the harm that screens can do to children, but from my experience the problem isn't the screens themselves, but what you do with them.
I've frequently been praised for my good English, which I learned mostly by playing videogames with a dictionary handy.
I also learned a lot from games.
You easily pick up knowledge and skills when you are a child, and most games offer educational content, brain teasers, or improve reflexes or fine motor skills.&lt;/p&gt;
&lt;p&gt;Anyway, here is an inevitably incomplete list of my favorite games from the 1990s:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Adventure games taught me puzzle-solving, humor and how to appreciate narratives, scripts and world building. Titles like Monkey Island 1 &amp;amp; 2, LOOM, Indiana Jones and the Fate of Atlantis, Day of the Tentacle, Sam and Max Hit the Road, Legend of Kyrandia 1 &amp;amp; 2, Simon the Sorcerer, Space Quest 1-6, King's Quest 1-6, Discworld, Full Throttle, The Dig and so many others.&lt;/li&gt;
&lt;li&gt;I was big into RPGs, or Role-Playing Games, because I also played a lot of tabletop RPGs. The Eye of the Beholder series, Dungeon Master 2, Ultima Underworld, Fallout 1 &amp;amp; 2, Anvil of Dawn, Wizardry VII, The Elder Scrolls, Dark Sun...&lt;/li&gt;
&lt;li&gt;Strategy: Civilization I &amp;amp; II, Colonization, Sim City, Sim City 2000, Sim Ant, Sim Earth, Master of Orion 1 &amp;amp; 2, Master of Magic, Star Control 2, Dune 2, Stronghold, Warcraft, Starcraft, Age of Empires, UFO Enemy Unknown, X-COM Terror from the Deep, Command &amp;amp; Conquer, Theme Park, Theme Hospital, The Settlers, Heroes of Might &amp;amp; Magic, Mad TV...&lt;/li&gt;
&lt;li&gt;Puzzle: Gobliiins 2 &amp;amp; 3, Lemmings 1 &amp;amp; 2, The Incredible Machine, Chip's Challenge, Blockout, Tetris...&lt;/li&gt;
&lt;li&gt;Arcade: Pac-Man, Doom 2, Duke Nukem 3D, Golden Axe, Raptor, Tyrian, Arkanoid, Descent 1 &amp;amp; 2, Grand Theft Auto, Pinball Fantasies, Tomb Raider, One Must Fall 2097, Cannon Fodder 1 &amp;amp; 2...&lt;/li&gt;
&lt;li&gt;Platform: Bubble Bobble, Prince of Persia, Commander Keen, Rick Dangerous 2, Rayman...&lt;/li&gt;
&lt;li&gt;Racing: Lotus Turbo Challenge, WipeOut, Carmageddon, Destruction Derby, IndyCar Racing, Test Drive...&lt;/li&gt;
&lt;li&gt;Sports: Advantage Tennis, Skate or Die, Ski or Die, Summer Games, Winter Games, 4D Sports Boxing, NBA Jam, PC Futbol, FIFA 94-98, Sensible Soccer, NBA Live, NHL Hockey...&lt;/li&gt;
&lt;li&gt;Flight simulators: Microsoft Flight Simulator 4 and 5, F-19 Stealth Fighter, F-15 Strike Eagle II, X-Wing, Tie Fighter, Strike Commander, Privateer 1 &amp;amp; 2, Comanche Maximum Overkill, Secret Weapons of the Luftwaffe...&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Looking back, I believe the key was to spend time at the computer.
Do that long enough, and you'll end up learning computing.&lt;/p&gt;
&lt;p&gt;It's like &lt;a href="https://web.archive.org/web/20250210040613/https://www.reddit.com/r/loseit/comments/2u75am/terry_crews_gives_sage_advice_on_going_to_the_gym/"&gt;this quote by Terry Crews&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Go to the gym, don't even work out. Just GO. Because the habit of going to the gym is more important than the work out. Because it doesn't matter what you do. You can have fun - but as long as you're having fun, you continue to do it.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h1 id="a-proper-computer"&gt;A proper computer&lt;/h1&gt;
&lt;p&gt;The most important tool to learn computing is, of course, a computer.
A &lt;em&gt;proper&lt;/em&gt; computer.
What do I mean by proper?
To put it simply, it's one that gives the user full control:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Install a different OS.&lt;/li&gt;
&lt;li&gt;Install any software you want (as long as it's OS and hardware compatible, of course).&lt;/li&gt;
&lt;li&gt;Play any media (again, limited by hardware).&lt;/li&gt;
&lt;li&gt;Program software without restriction, access the hardware in full.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This definition rules out all popular smartphones and tablets, both Android and iOS.
Not because of the touchscreens—notice that isn't among the requirements.
The point is to &lt;strong&gt;be the master of your own domain&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;iOS has been closed from the very beginning.
Its users trade sovereignty for comfort.
You can install the apps that Apple lets you install, and that is it.
They are convenient devices with great quality and polished interfaces and user experience—I won't dispute that—but their users are at the mercy of Apple.
Programming apps is also restricted, requiring a Mac computer to code and an Apple Developer account ($99/year) if you want to distribute.&lt;/p&gt;
&lt;p&gt;Android started as open-source, Linux at its core, but has since been corrupted by Google.
Virtually every Android device is now closed due to Google Play Services, a system component with root (i.e., full) access that, while not being part of the base Android operating system, is a required dependency for most Android applications to work.
Google also wants to &lt;a href="https://keepandroidopen.org/"&gt;require developers to register&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you are still not convinced, others have argued better than me for why it is important to have control over our computing devices:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.gnu.org/philosophy/right-to-read.en.html"&gt;https://www.gnu.org/philosophy/right-to-read.en.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://medhir.com/blog/right-to-root-access"&gt;https://medhir.com/blog/right-to-root-access&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hackaday.com/2025/10/22/what-happened-to-running-what-you-wanted-on-your-own-machine/"&gt;https://hackaday.com/2025/10/22/what-happened-to-running-what-you-wanted-on-your-own-machine/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;OK, OK, so no smartphones and no tablets.
What then?
Are "regular" computers safe?
Well for the most part yes, although there are also worrying trends that I don't want to get into right now.
They remain the bulwark of computing freedom, and it's paramount that they remain that way.&lt;/p&gt;
&lt;h1 id="choices"&gt;Choices&lt;/h1&gt;
&lt;p&gt;There are more choices for a computer available nowadays than ever before, so I'll be opinionated here.&lt;/p&gt;
&lt;p&gt;As a &lt;a href="https://www.fsf.org/resources/resources/what-is-fs"&gt;free-software&lt;/a&gt; advocate, I first discarded all closed-source systems.
That means no Windows or macOS.
I believe it's important for children to learn the &lt;a href="https://www.gnu.org/philosophy/free-sw.en.html"&gt;principles behind free software&lt;/a&gt; and to value not only its ethics but also the practicalities.
Using free software puts us in control of our digital lives, and as those lives grow larger, that control becomes more important.&lt;/p&gt;
&lt;p&gt;So some of the myriad GNU/Linux distributions will do the trick (more on that later).
Thus my first requirement for the hardware is perfect Linux compatibility.
This isn't a difficult task these days, though it wasn't so 20 years ago.&lt;/p&gt;
&lt;p&gt;For my children, I don't want to give them a laptop.
I'd rather have a desktop setup so they don't try to bring the computer everywhere.
This will make computer time more purposeful and easier to control.&lt;/p&gt;
&lt;p&gt;For desktop computers there is a &lt;a href="https://en.wikipedia.org/wiki/Desktop_computer#Types"&gt;wide range of options&lt;/a&gt;, from full-size towers to mini PCs and keyboard computers.
I knew I wanted something small and affordable to begin with, around 200€.&lt;/p&gt;
&lt;p&gt;As I researched, the keyboard computer form factor made me nostalgic for my ZX Spectrum+.
I instantly fell in love with the &lt;a href="https://www.raspberrypi.com/products/raspberry-pi-400-unit/"&gt;Raspberry Pi 400&lt;/a&gt;, but I was hoping for a bit more horsepower.
I looked for similar alternatives—found the &lt;a href="http://www.orangepi.org/html/hardWare/computerAndMicrocontrollers/details/orange-pi-800.html"&gt;Orange Pi 800&lt;/a&gt;, &lt;a href="https://pine64.org/devices/pinetab2/"&gt;PineTab2&lt;/a&gt;, &lt;a href="https://shop.cutiepi.io/products/cutiepi-tablet"&gt;CutiePi&lt;/a&gt;, &lt;a href="https://junocomputers.com/us/product/juno-tab-2/"&gt;Juno Tab 2&lt;/a&gt; and &lt;a href="https://raspad.com/products/raspadv3"&gt;RaspPad 3&lt;/a&gt;—but wasn't sold on any them for different reasons.
Then Raspberry Pi announced the &lt;a href="https://www.raspberrypi.com/products/raspberry-pi-500/"&gt;Pi 500&lt;/a&gt;, which looked great, except it lacked NVMe support.
I decided to play the waiting game, and was duly rewarded: the &lt;a href="https://www.raspberrypi.com/products/raspberry-pi-500-plus/"&gt;Raspberry Pi 500+&lt;/a&gt; was released recently.
It has NVMe, 16 GB of RAM &lt;em&gt;and&lt;/em&gt; a mechanical keyboard!&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/computing_education/raspberry_pi_500+_vs_zx_spectrum+.jpg" title="Click for full screen"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/computing_education/raspberry_pi_500+_vs_zx_spectrum+.jpg" title="Click for full screen"&gt;&lt;figcaption&gt;Raspberry Pi 500+ vs. ZX Spectrum+&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;h1 id="next-steps"&gt;Next steps&lt;/h1&gt;
&lt;p&gt;I just got my hands on the Raspberry Pi 500+, and I'm incredibly excited!
I'd love to show it to my daughter right away, but I'll wait until her birthday.
I'll prepare it to give her the best first experience possible, hoping for the magic to happen.&lt;/p&gt;
&lt;p&gt;The Raspberry Pi 500+ comes preinstalled with &lt;a href="https://www.raspberrypi.com/software/operating-systems/"&gt;Raspbian OS&lt;/a&gt;, which is a good option, but I need to evaluate it for a 7-year-old’s needs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;No root account (for now).&lt;/li&gt;
&lt;li&gt;No Internet access, and heavily filtered later on.&lt;/li&gt;
&lt;li&gt;Educational software.&lt;/li&gt;
&lt;li&gt;Creative software (paint, music, video, printing...)&lt;/li&gt;
&lt;li&gt;Programming environments.&lt;/li&gt;
&lt;li&gt;Age-appropriate videogames.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So my next step is to research child-friendly GNU/Linux distributions.
I expect requiring Raspberry Pi compatibility will narrow down the search.
I don’t think I'll find a distribution that perfectly fits my requirements, but I just want to find a good base setup.&lt;/p&gt;
&lt;p&gt;One of my takeaways from all this is that, given how excited as I am about educating children in computing, it might be my calling.
I'm sure other parents would like to teach their children responsible computing but might not have the time or resources.
I plan on documenting my efforts and create resources to help and guide them—this article is the first step.&lt;/p&gt;
&lt;p&gt;&lt;a href="/pages/subscribe"&gt;Stay tuned&lt;/a&gt; for updates!&lt;/p&gt;</content><category term="Software"/><category term="education"/><category term="free-software"/><category term="linux"/></entry><entry><title>Now with self-hosted comments</title><link href="https://blog.fidelramos.net/software/self-hosted-comments" rel="alternate"/><published>2025-02-10T00:00:00+00:00</published><updated>2025-02-10T17:44:07.882735+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2025-02-10:/software/self-hosted-comments</id><summary type="html">&lt;p&gt;A few days ago I received an email from a reader and &lt;a href="https://zenodotus280.mataroa.blog/"&gt;fellow blogger&lt;/a&gt;, to let me know that he was following my blog and also gave me a shout out about &lt;a href="https://blog.fidelramos.net/personal/new-years-resolutions-2025#blog"&gt;my New Year's resolution to write at least one article every month of 2025&lt;/a&gt;, and that I was …&lt;/p&gt;</summary><content type="html">&lt;p&gt;A few days ago I received an email from a reader and &lt;a href="https://zenodotus280.mataroa.blog/"&gt;fellow blogger&lt;/a&gt;, to let me know that he was following my blog and also gave me a shout out about &lt;a href="https://blog.fidelramos.net/personal/new-years-resolutions-2025#blog"&gt;my New Year's resolution to write at least one article every month of 2025&lt;/a&gt;, and that I was already running behind.&lt;/p&gt;
&lt;p&gt;I love getting messages like that, because if one person takes the trouble to email me I'm sure there are 99 others who have thought about it but haven't done so.
That's why after receiving the email I decided to give priority to something I had been planning for a long time: adding comments to the blog.
Spoiler alert: &lt;strong&gt;goal achieved&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;I wanted it to be something fast, so instead of looking for the perfect option I was going to optimize for the one that would take me the least time to implement.
That meant starting by trying out the options already built into the &lt;a href="https://github.com/haplo/reflex"&gt;Reflex&lt;/a&gt; theme I use on the blog (fork of &lt;a href="https://github.com/alexandrevicenzi/Flex"&gt;Flex&lt;/a&gt;):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://disqus.com/"&gt;Disqus&lt;/a&gt;: probably the most popular commenting platform on the web.
It is very convenient and powerful, but totally closed and most likely spies and sells our data.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://isso-comments.de/"&gt;Isso&lt;/a&gt;: self-hosted Disqus-style software.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Those who know me will think that I would have jumped head first for Isso, but honestly my first attempt was with Disqus, because it would have been the quickest thing to do: open an account, put a password in the Pelican options and go.
Well, my plan quickly run into a wall because it turns out that &lt;a href="https://disqus.com/pricing/"&gt;Disqus no longer has a free plan&lt;/a&gt;!
I don't mind paying for worthwhile services, but not for comments on this humble blog, so I forgot about Disqus and started looking at the details of Isso.&lt;/p&gt;
&lt;p&gt;I liked what I saw about Isso:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Self-hosted on my own domain.&lt;/li&gt;
&lt;li&gt;Programmed in Python: easy installation in a virtualenv and it would be particularly easy for me to collaborate with the project if needed.&lt;/li&gt;
&lt;li&gt;SQLite as database, no need for a MariaDB or Postgres server.
This was one of the main reasons why &lt;a href="https://blog.fidelramos.net/software/privacy-respecting-self-hosted-web-analytics"&gt;I chose Shynet for blog analytics&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The only thing I didn't like about Isso is that development seems a bit stalled, there are several PRs that have been waiting for months for merge.
But it doesn't seem to be a dead project, I'm hopeful that there will continue to be new versions at least to keep it up to date in terms of compatibility and security.&lt;/p&gt;
&lt;p&gt;By the way, I ended up discovering &lt;a href="https://github.com/umputun/remark42/"&gt;Remark42&lt;/a&gt; as an alternative, after having deployed Isso on my server.
It looks very good both in terms of features and development status, but personally I would have continued to choose Isso because it is already integrated into my blog theme.&lt;/p&gt;
&lt;h2 id="technical-details"&gt;Technical details&lt;/h2&gt;
&lt;p&gt;I installed Isso on the same server that hosts this blog, a humble VPS with 2 GB of RAM.
I didn't need but to follow the &lt;a href="https://isso-comments.de/docs/reference/installation/#init-scripts"&gt;official documentation&lt;/a&gt;, but in case anyone is interested this was my process:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Install &lt;em&gt;isso&lt;/em&gt; and a WSGI server (I favor gunicorn):&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# create new user for isolation&lt;/span&gt;
adduser&lt;span class="w"&gt; &lt;/span&gt;--disabled-password&lt;span class="w"&gt; &lt;/span&gt;isso

&lt;span class="c1"&gt;# install isso&lt;/span&gt;
su&lt;span class="w"&gt; &lt;/span&gt;-&lt;span class="w"&gt; &lt;/span&gt;isso
virtualenv&lt;span class="w"&gt; &lt;/span&gt;venv
&lt;span class="nb"&gt;source&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;venv/bin/activate
pip&lt;span class="w"&gt; &lt;/span&gt;install&lt;span class="w"&gt; &lt;/span&gt;isso&lt;span class="w"&gt; &lt;/span&gt;gunicorn
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Create server configuration file in &lt;em&gt;/home/isso/isso.cfg&lt;/em&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c1"&gt;# https://isso-comments.de/docs/reference/server-config/&lt;/span&gt;

&lt;span class="k"&gt;[general]&lt;/span&gt;
&lt;span class="na"&gt;dbpath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;/home/isso/comments.db&lt;/span&gt;
&lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;https://blog.fidelramos.net/&lt;/span&gt;
&lt;span class="na"&gt;notify&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;smtp&lt;/span&gt;

&lt;span class="k"&gt;[server]&lt;/span&gt;
&lt;span class="na"&gt;listen&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;http://localhost:8081/&lt;/span&gt;

&lt;span class="k"&gt;[smtp]&lt;/span&gt;
&lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;localhost&lt;/span&gt;
&lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;25&lt;/span&gt;
&lt;span class="na"&gt;security&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;
&lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;lt;notification_email@example.com&amp;gt;&lt;/span&gt;
&lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;isso@blog.fidelramos.net&lt;/span&gt;
&lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;5&lt;/span&gt;

&lt;span class="k"&gt;[hash]&lt;/span&gt;
&lt;span class="na"&gt;salt&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;Eech7co8Ohloopo9Ol6baimi&lt;/span&gt;
&lt;span class="na"&gt;algorithm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;pbkdf2:10000:6:sha256&lt;/span&gt;

&lt;span class="k"&gt;[admin]&lt;/span&gt;
&lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;lt;PASSWORD&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Set up reverse proxy through Caddy by adding to &lt;em&gt;/etc/caddy/Caddyfile&lt;/em&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;isso.fidelramos.net {
    reverse_proxy localhost:8081
    encode zstd gzip
}
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Add a systemd service unit at &lt;em&gt;/etc/systemd/system/isso.service&lt;/em&gt; to run isso. &lt;a href="https://isso-comments.de/docs/reference/installation/#init-scripts"&gt;Isso documentation includes other init systems&lt;/a&gt;.&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;isso commenting system&lt;/span&gt;
&lt;span class="na"&gt;Documentation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;man:isso(8)&lt;/span&gt;

&lt;span class="c1"&gt;# Require the filesystems containing the following directories to be mounted&lt;/span&gt;
&lt;span class="na"&gt;RequiresMountsFor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/isso&lt;/span&gt;

&lt;span class="na"&gt;ConditionPathIsDirectory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/isso&lt;/span&gt;
&lt;span class="na"&gt;ConditionPathIsReadWrite&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/isso&lt;/span&gt;

&lt;span class="na"&gt;ConditionFileIsExecutable&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/isso/venv/bin/gunicorn&lt;/span&gt;

&lt;span class="c1"&gt;# Start only if there is a conf file&lt;/span&gt;
&lt;span class="na"&gt;ConditionPathExistsGlob&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/isso/isso.cfg&lt;/span&gt;

&lt;span class="k"&gt;[Service]&lt;/span&gt;

&lt;span class="na"&gt;Environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;ISSO_SETTINGS="/home/isso/isso.cfg"&lt;/span&gt;
&lt;span class="na"&gt;ExecStart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;/home/isso/venv/bin/gunicorn -b localhost:8081 -w 4 --preload -n gunicorn-isso --log-file /home/isso/isso.log isso.run&lt;/span&gt;

&lt;span class="c1"&gt;#UMask=0007&lt;/span&gt;
&lt;span class="na"&gt;Restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;on-failure&lt;/span&gt;
&lt;span class="na"&gt;TimeoutSec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;
&lt;span class="na"&gt;User&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;isso&lt;/span&gt;

&lt;span class="na"&gt;LimitNOFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;16384&lt;/span&gt;
&lt;span class="na"&gt;LimitNPROC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;16384&lt;/span&gt;
&lt;span class="na"&gt;LimitLOCKS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;16384&lt;/span&gt;

&lt;span class="c1"&gt;# ensures that the service process and all its children can never gain new&lt;/span&gt;
&lt;span class="c1"&gt;# privileges.&lt;/span&gt;
&lt;span class="na"&gt;NoNewPrivileges&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;

&lt;span class="k"&gt;[Install]&lt;/span&gt;
&lt;span class="na"&gt;WantedBy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;multi-user.target&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Add A and/or AAAA records to your DNS (&lt;em&gt;isso.fidelramos.net&lt;/em&gt; in my case) pointing to the server.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Enable and start &lt;em&gt;isso&lt;/em&gt;, and reload &lt;em&gt;caddy&lt;/em&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;systemctl&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;enable&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;isso
systemctl&lt;span class="w"&gt; &lt;/span&gt;start&lt;span class="w"&gt; &lt;/span&gt;isso
systemctl&lt;span class="w"&gt; &lt;/span&gt;reload&lt;span class="w"&gt; &lt;/span&gt;caddy
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Now I can access &lt;a href="https://isso.fidelramos.net/admin"&gt;isso.fidelramos.net/admin&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Add configuration to &lt;em&gt;pelicanconf.py&lt;/em&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="n"&gt;ISSO_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"//isso.fidelramos.net"&lt;/span&gt;
&lt;span class="n"&gt;ISSO_OPTIONS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"vote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"false"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Deploy the blog. Comments are live! 🎉&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="the-future"&gt;The future&lt;/h2&gt;
&lt;p&gt;I'm looking forward to seeing how much use the comments will get.
I'm sure they will encourage me to write more.&lt;/p&gt;
&lt;p&gt;Spam worries me, because Isso has no automatic filtering system.
I don't want to anticipate solving a problem before it manifests itself, but we'll see how long the peace lasts....&lt;/p&gt;
&lt;p&gt;Did you make it this far? You can't leave without leaving a comment!&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="blog"/><category term="free-software"/><category term="howto"/><category term="linux"/><category term="privacy"/><category term="self-hosting"/></entry><entry><title>My homelab setup</title><link href="https://blog.fidelramos.net/software/homelab" rel="alternate"/><published>2023-12-29T00:00:00+00:00</published><updated>2026-03-30T08:56:38.411445+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2023-12-29:/software/homelab</id><summary type="html">&lt;p&gt;Welcome to my homelab!&lt;/p&gt;
&lt;p&gt;If you don't know what a homelab is, it refers to running servers from your home.
What is a server?
It's basically a computer that is always on, connected to the network, often with no display or input.
For a longer explanation take a look at …&lt;/p&gt;</summary><content type="html">&lt;p&gt;Welcome to my homelab!&lt;/p&gt;
&lt;p&gt;If you don't know what a homelab is, it refers to running servers from your home.
What is a server?
It's basically a computer that is always on, connected to the network, often with no display or input.
For a longer explanation take a look at &lt;a href="https://linuxhandbook.com/homelab/"&gt;this article&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The homelab movement crosses paths with self-hosting (running software services for your own use), but they are not the same thing.
You can have a homelab not dedicated to self-hosting services.
You can self-host using computers not in your home (e.g. on a hosting provider).
But I believe there is a considerable overlap of people interested in both, as is my case.&lt;/p&gt;
&lt;p&gt;My setup is quite involved and some people will be reading this article and think &amp;ldquo;this is way too complicated, I cannot do any of this&amp;rdquo;.
And they would be right!
BUT, the good news is that you don't have to do things the same way I do.
There is a whole gradient of possibilities with varying degrees of difficulty, effort, reward, control and price, and it's normal to combine them:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Use paid hosted services, i.e. you pay and get access to a software that somebody else manages.
You don't get as much control but it's simpler, data might be encrypted and the provider doesn't have the incentive to sell your data (because you already pay for the service).
Some of these services are actually open-source software, and you can later migrate to your own instance if you so desire.
As an example, I use &lt;a href="https://pr.tn/ref/1024DVCB1T20"&gt;Protonmail&lt;/a&gt; as my email provider, because setting up your own email is &lt;a href="https://cfenollosa.com/blog/after-self-hosting-my-email-for-twenty-three-years-i-have-thrown-in-the-towel-the-oligopoly-has-won.html"&gt;ever more complicated&lt;/a&gt; to get right.&lt;/li&gt;
&lt;li&gt;Use hosting services that make it easy to deploy apps.
Same as the previous point, I would prioritize open-source services that you could migrate to your own instance later on.
Some examples are &lt;a href="https://sandstorm.io/"&gt;Sandstorm&lt;/a&gt; and &lt;a href="https://yunohost.org/#/"&gt;YunoHost&lt;/a&gt;.
I personally used &lt;a href="https://owncube.com/single_en.html"&gt;OwnCube&lt;/a&gt; before migrating to my own self-hosted Nextcloud instance.&lt;/li&gt;
&lt;li&gt;Run your own services, but on off-premises hardware (i.e. with a hosting provider, or in &amp;ldquo;the cloud&amp;rdquo;).
There is a wide range of hosting possibilities, from dedicated servers (i.e. a whole real computer) to shared hosting where you share a computerf with other users (separated by some virtualization or containerization techniques), and others.
You can read &lt;a href="https://en.wikipedia.org/wiki/Internet_hosting_service"&gt;Wikipedia on Internet hosting&lt;/a&gt; for more details.
Personally I use &lt;a href="https://www.vultr.com/?ref=7148856"&gt;Vultr&lt;/a&gt; to host some services I want to be always online and publicly accessible to the Internet.&lt;/li&gt;
&lt;li&gt;Dedicate a computer at home as a server.
You can start cheap and small, like a &lt;a href="https://www.raspberrypi.com/products/raspberry-pi-5"&gt;Raspberry Pi 5&lt;/a&gt; for $80.
Or you can use some spare computer you don't need anymore, like an old laptop.
You can evolve your homelab as you understand your needs better.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There are countless resources on self-hosting and homelabs, if anything the difficulty lies in knowing where to start.
My recommendation is to ask people you trust about what to focus on to avoid getting overwhelmed.&lt;/p&gt;
&lt;p&gt;Nevertheless, here are some resources:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/mikeroyal/Self-Hosting-Guide"&gt;Self-Hosting Guide&lt;/a&gt; is a thorough overview.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/awesome-selfhosted/awesome-selfhosted"&gt;Awesome Selfhosted&lt;/a&gt; has a long list of self-hostable applications.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://lemmy.world/c/selfhosted"&gt;selfhosted at Lemmy.World&lt;/a&gt; is a good community of self-hosters, who migrated from the &lt;a href="https://www.reddit.com/r/selfhosted/"&gt;selfhosted subreddit&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No matter what you choose to do I encourage you to read and educate yourself about &lt;a href="https://en.wikipedia.org/wiki/Digital_privacy"&gt;digital privacy&lt;/a&gt; and &lt;a href="https://www.gnu.org/philosophy/"&gt;free software&lt;/a&gt;.
I believe it's time well spent because our lives are increasingly more digital, not knowing about these topics leave you open to abuse.&lt;/p&gt;
&lt;p&gt;Lastly I want to thank my friend Harmeet for his encouragement to write this article.
It probably wouldn't have happened without him!&lt;/p&gt;
&lt;div class="toc"&gt;&lt;span class="toctitle"&gt;Table of Contents&lt;/span&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#some-history"&gt;Some history&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#goals"&gt;Goals&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#summary"&gt;Summary&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#home-server"&gt;Home server&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#storage"&gt;Storage&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#network"&gt;Network&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#ups"&gt;UPS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#applications"&gt;Applications&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#caddy"&gt;Caddy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#nextcloud"&gt;Nextcloud&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#transmission"&gt;Transmission&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#mumble"&gt;Mumble&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#htpc-home-theater-pc"&gt;HTPC (Home-Theater PC)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#vps"&gt;VPS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#replication"&gt;Replication&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#backups"&gt;Backups&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#uptime-monitoring"&gt;Uptime monitoring&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#the-future"&gt;The future&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id="some-history"&gt;Some history&lt;/h2&gt;
&lt;p&gt;I'm old in Internet terms.
Ancient.&lt;/p&gt;
&lt;p&gt;I was browsing the World Wide Web and participating in Usenet in 1995.
In 1999 I tried out GNU/Linux for the first time.
By 2003 I had eradicated Windows from all my computers.
The free software bug really bit me deep.&lt;/p&gt;
&lt;p&gt;During this time I saw Google's birth as the best search engine of its time, hands down.
They had the best developers and made some amazing products.
I really believed in Google's &amp;ldquo;Don't be evil&amp;rdquo; slogan, and was an enthusiastic user for years.
I got a Gmail account back when it required an invite to get in.
I put my events in Google Calendar, my photos in Google Photos, my (shared) documents in Google Drive.
I got an Android phone because it was open source.
I even used Google+ and Google Wave actively.&lt;/p&gt;
&lt;p&gt;Fast-forward to 2013 and &lt;a href="https://en.wikipedia.org/wiki/Edward_Snowden#Revelations"&gt;Edward Snowden's revelations&lt;/a&gt; shatter my confidence to pieces.
Google was not to be trusted, nor any of the big Silicon Valley companies.
I resolved to trace a plan to take back control of my digital life.
That plan took years to unfold, but every day it passes I'm more glad that I put in the effort.&lt;/p&gt;
&lt;p&gt;This article will not be a chronological story, but only the latest version of my setup.&lt;/p&gt;
&lt;h2 id="goals"&gt;Goals&lt;/h2&gt;
&lt;p&gt;My main goal should be clear after reading the story above: take control of my data.
I don't want others to abuse it, sell it to innumerable third parties that will analyze it, profile it, use it to target me with ads or misinformation campaigns, train AI models with it...
I also don't want to see my accounts closed one day for no good reason, my valuable data gone like tears in the rain.
Having my data at home gives me peace of mind.&lt;/p&gt;
&lt;p&gt;A secondary benefit of having all my data locally available is that I get to do stuff with it.
Things like directly sharing with people, without size limits or accounts nor anybody else knowing about it.&lt;/p&gt;
&lt;p&gt;With the advent of &lt;a href="https://huggingface.co/"&gt;powerful open-source AI models&lt;/a&gt; I'm excited about the possibilities of having my data readily available:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Train LLMs with all my text, so I can query it for knowledge or ask it to write in my style.&lt;/li&gt;
&lt;li&gt;Fine-tune Stable Diffusion models using my photos so I can generate images portraying me, my family and friends.&lt;/li&gt;
&lt;li&gt;Use coding LLMs to analyze and write code.&lt;/li&gt;
&lt;li&gt;Train voice models with my own voice, I could make podcasts out of these blog posts for example.&lt;/li&gt;
&lt;li&gt;Who knows what the future holds! The field is moving at breakneck speed.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;My guiding principles are &lt;em&gt;simplicity&lt;/em&gt; and &lt;em&gt;stability&lt;/em&gt;.
The less moving pieces the better.
I favor software that is light on resources, properly maintained and that respects backwards compatibility or has clear ways to upgrade.
I have limited time so I don't want things to break often.&lt;/p&gt;
&lt;p&gt;Using &lt;a href="https://www.gnu.org/philosophy/"&gt;free software&lt;/a&gt; is paramount so I can understand what is going on, diagnose problems and fix them, and also that I can trust the software is not doing something I don't want it to.&lt;/p&gt;
&lt;h2 id="summary"&gt;Summary&lt;/h2&gt;
&lt;p&gt;My current setup is composed of 3 systems, not counting regular personal computers (laptop, desktop, mobile, tablet...):&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A home server for data storage and personal apps.&lt;/li&gt;
&lt;li&gt;A HTPC (Home Theater PC) media player.&lt;/li&gt;
&lt;li&gt;A VPS for public apps/websites.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I will go over each of them in detail.&lt;/p&gt;
&lt;h2 id="home-server"&gt;Home server&lt;/h2&gt;
&lt;p&gt;The heart of my setup is an HPE ProLiant MicroServer Gen10, bought in 2018.
It has a modest dual-core AMD Opteron X3216 CPU and &amp;ldquo;only&amp;rdquo; 8 GB of RAM, but that has proven plenty for my needs, which mostly boil down to storage.
It costed 375€ (about $400) back in the day.&lt;/p&gt;
&lt;p&gt;The main selling point for me was having a sufficiently powerful GPU, because at first it doubled as an &lt;a href="https://en.wikipedia.org/wiki/Home_theater_PC"&gt;HTPC&lt;/a&gt;.
Nowadays I have a dedicated HTPC media player, so my pick would have been different, but I'm not replacing it yet.&lt;/p&gt;
&lt;p&gt;The server has been happily running &lt;a href="https://www.debian.org/"&gt;Debian GNU/Linux&lt;/a&gt; stable all this time.
I have been using Debian for over 2 decades and it feels right for servers.
If I had more free time I would love to dive into &lt;a href="https://nixos.org/"&gt;NixOS&lt;/a&gt; or &lt;a href="https://guix.gnu.org/"&gt;Guix&lt;/a&gt;, they make for rock-solid systems due to their reproducibility and ability to roll-back to any previous system configuration.&lt;/p&gt;
&lt;h3 id="storage"&gt;Storage&lt;/h3&gt;
&lt;p&gt;The server has a 256 GB SSD that I use for the OS and software, and then 4x 6 TB HDDs for data hosting.
Storage keeps getting better and cheaper all the time.
I paid $189 for each HDD, but nowadays you can a 10 or 12 TB drive for the same price.&lt;/p&gt;
&lt;p&gt;Reliability is very important to me, as these hard drives will hold my personal data.
I picked the top performer in &lt;a href="https://www.backblaze.com/blog/backblaze-drive-stats-for-2022/"&gt;Backblaze's drive reliability report&lt;/a&gt; and all 4 drives have been performing well since 2018.&lt;/p&gt;
&lt;p&gt;I use &lt;a href="https://www.snapraid.it/"&gt;SnapRAID&lt;/a&gt; to have a software RAID 5, by dedicating one HDD for parity.
That leaves the server with 18 TB of available storage.
A cron job runs a SnapRAID sync every night and emails on success or failure.&lt;/p&gt;
&lt;p&gt;If a drive fails I might lose up to 24 hours of data, but it shouldn't be fatal as the data should be available somewhere else (e.g. my photos are also in my computer).
I prefer the software RAID as I can recover files or undo unwanted changes before the next sync runs.
This has proven useful more than once.&lt;/p&gt;
&lt;p&gt;The 3 non-parity HDDs are combined into a single logical filesystem using &lt;a href="https://github.com/trapexit/mergerfs"&gt;MergerFS&lt;/a&gt;.
It has worked fine all these years, set and forget.&lt;/p&gt;
&lt;p&gt;All drives are &lt;a href="https://en.wikipedia.org/wiki/Linux_Unified_Key_Setup"&gt;LUKS-encrypted&lt;/a&gt;.
Because the server is headless I &lt;a href="https://blog.fidelramos.net/software/unlock-luks-usb-drive"&gt;set it up so it can be decrypted on boot by connecting an USB drive&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id="network"&gt;Network&lt;/h3&gt;
&lt;p&gt;The server is exposed to the Internet because it's going to run services I want to use from anywhere.
I configured my home router to give the server a static IP address, and set it as the &lt;a href="https://en.wikipedia.org/wiki/DMZ_(computing)"&gt;DMZ&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I don't have a dedicated IP address in my home internet connection, so I use a &lt;a href="https://github.com/jamesob/gandi-ddns/"&gt;Python script&lt;/a&gt; that updates the necessary DNS records by using my DNS provider's APIs.
A cron job executes the script every 10 minutes.&lt;/p&gt;
&lt;p&gt;I use &lt;a href="https://en.wikipedia.org/wiki/Firewalld"&gt;Firewalld&lt;/a&gt; to manage the firewall.
I was using &lt;a href="https://en.wikipedia.org/wiki/Uncomplicated_Firewall"&gt;UFW&lt;/a&gt; before but &lt;a href="https://github.com/chaifeng/ufw-docker"&gt;it didn't play well with Docker&lt;/a&gt;: it will pierce the firewall with the default configuration.&lt;/p&gt;
&lt;p&gt;I allow external SSH connections, but only with public key encryption, never passwords.&lt;/p&gt;
&lt;p&gt;I use &lt;a href="https://en.wikipedia.org/wiki/Samba_(software)"&gt;Samba&lt;/a&gt; for file access.&lt;/p&gt;
&lt;h3 id="ups"&gt;UPS&lt;/h3&gt;
&lt;p&gt;I use an &lt;a href="https://www.eaton.com/sg/en-us/skuPage.5E850IUSB.specifications.html"&gt;Eaton 5E 850 IUSB &lt;/a&gt; in front of the home server, the router and the fiber modem.
This gives me a few minutes of runtime if the power goes down.
I paid 62€ for it in 2018.
A good UPS is expensive, this one is not.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://networkupstools.org/"&gt;NUT&lt;/a&gt; monitors the UPS and gracefully shuts down the server if its battery drops below a certain threshold.&lt;/p&gt;
&lt;h3 id="applications"&gt;Applications&lt;/h3&gt;
&lt;p&gt;The home server runs applications that fulfill these criteria:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Used by me and maybe family/friends.&lt;/li&gt;
&lt;li&gt;Need access to private data.&lt;/li&gt;
&lt;li&gt;Heavier on CPU/RAM/storage than I'd like to put on the VPS.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All applications run with &lt;a href="https://docs.docker.com/compose/"&gt;Docker compose&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="caddy"&gt;Caddy&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://caddyserver.com/"&gt;Caddy&lt;/a&gt; is a webserver focusing on ease of use.
It improves my life by having much simpler configuration with better defaults and &lt;em&gt;automated TLS certificates&lt;/em&gt;.
Yes, you read that right: Caddy will automatically obtain and renew a TLS certificate for every domain in its configuration.
No certbot, no cron, nothing but bliss.&lt;/p&gt;
&lt;p&gt;My setup in Docker compose uses the &lt;a href="https://github.com/lucaslorentz/caddy-docker-proxy"&gt;caddy-docker-proxy&lt;/a&gt; image.
It automates generating a Caddy configuration by looking at the labels of the containers.
It's simple, flexible and effective.&lt;/p&gt;
&lt;p&gt;Previously I was using the popular &lt;a href="https://nginx.org/en/"&gt;Nginx&lt;/a&gt; (and I'm old enough to have used Apache).
See &lt;a href="https://blog.fidelramos.net/software/switch-nginx-caddy-docker-compose"&gt;this article on my migration from nginx-proxy to caddy-docker-proxy&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="nextcloud"&gt;Nextcloud&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://github.com/nextcloud/"&gt;Nextcloud&lt;/a&gt; is &amp;ldquo;&lt;em&gt;a safe home for all your data&lt;/em&gt;&amp;rdquo;.
It's a platform that not only hosts and syncs files across multiple devices, but it offers a plethora of applications (both first and third-party) to do all kinds of things.&lt;/p&gt;
&lt;p&gt;The apps I currently use include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Files&lt;/li&gt;
&lt;li&gt;Calendar and Contacts (synced with my phone using &lt;a href="https://www.davx5.com/"&gt;DAVx5&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Photos (need to try &lt;a href="https://apps.nextcloud.com/apps/memories"&gt;Memories&lt;/a&gt;, it's supposed to be much better)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apps.nextcloud.com/apps/spreed"&gt;Talk&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apps.nextcloud.com/apps/news"&gt;News&lt;/a&gt; (RSS reader)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apps.nextcloud.com/apps/polls"&gt;Polls&lt;/a&gt; (Doodle replacement)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apps.nextcloud.com/apps/bookmarks"&gt;Bookmarks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://apps.nextcloud.com/apps/appointments"&gt;Appointments&lt;/a&gt; (Calendly replacement)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;You can browse the &lt;a href="https://apps.nextcloud.com/"&gt;full list of Nextcloud apps&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;My setup currently uses the &lt;a href="https://github.com/nextcloud/docker"&gt;nextcloud image&lt;/a&gt;, but this is suboptimal because it runs an Apache instance internally.
Given that I'm already running Caddy in front it's a complete waste of resources, but not so much to make it urgent.&lt;/p&gt;
&lt;p&gt;I'm assessing migrating to the official &lt;a href="https://github.com/nextcloud/all-in-one"&gt;all-in-one image&lt;/a&gt;.
I will write a blog post about it when it's done.&lt;/p&gt;
&lt;h4 id="transmission"&gt;Transmission&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://transmissionbt.com/"&gt;Transmission&lt;/a&gt; is a BitTorrent client that can run as a daemon, i.e. it runs in the server and I connect to it from my laptop or my phone.&lt;/p&gt;
&lt;p&gt;I use the &lt;a href="https://github.com/linuxserver/docker-transmission"&gt;linuxserver/docker-transmission Docker image&lt;/a&gt;.&lt;/p&gt;
&lt;h4 id="mumble"&gt;Mumble&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://www.mumble.info/"&gt;Mumble&lt;/a&gt; is an &amp;ldquo;&lt;em&gt;Open Source, Low Latency, High Quality Voice Chat&lt;/em&gt;&amp;rdquo;.
I use it mostly for gaming where low-latency really makes a difference.&lt;/p&gt;
&lt;p&gt;I use the &lt;a href="https://github.com/mumble-voip/mumble-docker"&gt;mumble-docker image&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="htpc-home-theater-pc"&gt;HTPC (Home-Theater PC)&lt;/h2&gt;
&lt;p&gt;Originally the home server doubled as media player: it was in the living room and it has a good-enough graphics card to play videos.
However after moving to a new house the server is in a different room, so I had to get some kind of HTPC to play my media.&lt;/p&gt;
&lt;p&gt;This move happened during the COVID-19 chip shortage so my &lt;a href="https://ameridroid.com/products/odroid-n2-plus"&gt;preferred option&lt;/a&gt; was not available.
I couldn't wait so I ended up getting a &lt;a href="https://www.raspberrypi.com/products/raspberry-pi-4-model-b/"&gt;Raspberry Pi 4 Model B&lt;/a&gt;, which was in stock.&lt;/p&gt;
&lt;p&gt;Nevertheless the Raspberry Pi performs well enough, decodes H.264 and H.265 in hardware, can do audio passthrough to my home theater, and is definitely a solid choice with a big community and great compatibility.&lt;/p&gt;
&lt;p&gt;It currently runs &lt;a href="https://kodi.tv/"&gt;Kodi&lt;/a&gt; in &lt;a href="https://libreelec.tv/"&gt;LibreELEC&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;It accesses the media files in the home server through a read-only &lt;a href="https://en.wikipedia.org/wiki/Samba_(software)"&gt;Samba&lt;/a&gt; share.&lt;/p&gt;
&lt;h2 id="vps"&gt;VPS&lt;/h2&gt;
&lt;p&gt;I have a small VPS (Virtual Private Server) at &lt;a href="https://www.vultr.com/?ref=7148856"&gt;Vultr&lt;/a&gt; that I dedicate to public content, like this blog and my personal website.
It's meant to run services that are open to the Internet, and that should be always up.
This is not part of the homelab of course, just self-hosting, but I thought it'd be valuable to include here.&lt;/p&gt;
&lt;p&gt;I use &lt;a href="https://caddyserver.com/"&gt;Caddy&lt;/a&gt; webserver for all static file serving and reverse proxying.
Its benefits were already highlighted above so I will not repeat them here.&lt;/p&gt;
&lt;p&gt;Besides web hosting the VPS also runs a &lt;a href="https://github.com/milesmcc/shynet/"&gt;Shynet&lt;/a&gt; instance that &lt;a href="https://blog.fidelramos.net/software/privacy-respecting-self-hosted-web-analytics"&gt;tracks analytics in a privacy-respecting way&lt;/a&gt;.
I &lt;a href="https://github.com/milesmcc/shynet/issues/208"&gt;got it to work with a SQLite DB&lt;/a&gt;, which simplifies deployment and maintenance.
I also use &lt;a href="https://podman.io/"&gt;Podman&lt;/a&gt; to run it.
I prefer Podman over Docker because it's lighter (no daemon running) and it's rootless without extra hacks.&lt;/p&gt;
&lt;p&gt;If you want to try out Vultr consider using my &lt;a href="https://www.vultr.com/?ref=7148856"&gt;referral link&lt;/a&gt; for some free credits.&lt;/p&gt;
&lt;h2 id="replication"&gt;Replication&lt;/h2&gt;
&lt;p&gt;To synchronize data between devices I use &lt;a href="https://syncthing.net/"&gt;Syncthing&lt;/a&gt;.
It's a beautiful piece of software, fast and end-to-end encrypted.
I highly recommend it over something like Dropbox, which &lt;a href="https://www.techspot.com/news/101202-dropbox-new-ai-features-sends-files-openai-when.html"&gt;is sharing your files with OpenAI&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;You might be thinking why would I use a separate app for file syncing when I already got Nextcloud.
When I started using Nextcloud in 2018 its file syncing was very buggy for me, which is why I looked at more robust alternatives.
From what I hear Nextcloud file syncing has gotten much better, but now I'm happy with my Syncthing setup.
&lt;em&gt;If it ain't broke...&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I won't go into the details of my whole setup, but these are some Syncthing tips &amp;amp; tricks I have been discovering and applying:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;The home server acts as a central always-online node.
For example I synchronize the photos taken in my smartphone with my laptop, I also share the same directory with the home server so the phone can sync to it even if the laptop is offline.
Then when the laptop comes online it can download from the server and/or the phone if available.&lt;/li&gt;
&lt;li&gt;Syncthing supports configuring &lt;a href="https://docs.syncthing.net/users/config.html#config-option-device.address"&gt;multiple addresses per device&lt;/a&gt;.
Most of the time my devices are in my local network so I set local network addresses first, then DNS names and then &lt;em&gt;dynamic&lt;/em&gt; as a fallback.&lt;/li&gt;
&lt;li&gt;Use &lt;a href="https://en.wikipedia.org/wiki/QUIC"&gt;QUIC protocol&lt;/a&gt; if possible, especially on mobile.&lt;/li&gt;
&lt;li&gt;Judicious use of &lt;a href="https://docs.syncthing.net/users/versioning.html"&gt;file versioning&lt;/a&gt;.
I'm particularly liberal versioning files in the server, because it's got plenty of storage.
I favor &lt;a href="https://docs.syncthing.net/users/versioning.html#staggered-file-versioning"&gt;staggered file versioning&lt;/a&gt; most of the time.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="backups"&gt;Backups&lt;/h2&gt;
&lt;p&gt;I use &lt;a href="https://restic.net/"&gt;restic&lt;/a&gt; to make weekly backups of all important data in all servers. Important is anything that cannot be downloaded again from the Internet.
It has worked well all these years, I only had to use it once and it performed as expected.
My current backup is 2,5 TB big and it takes 8 hours to run, a few more hours if &lt;em&gt;restic prune&lt;/em&gt; runs.&lt;/p&gt;
&lt;p&gt;The script that &lt;em&gt;cron&lt;/em&gt; runs weekly is &lt;a href="https://gist.github.com/haplo/db12fc973122366ab1e8cb0d17afbd0c"&gt;published here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If I had to set up the backups again today I would look into &lt;a href="https://www.borgbackup.org/"&gt;Borg&lt;/a&gt;.
They have a nice desktop client called &lt;a href="https://vorta.borgbase.com/"&gt;Vorta&lt;/a&gt; which I have been using for backing up my laptop.&lt;/p&gt;
&lt;h2 id="uptime-monitoring"&gt;Uptime monitoring&lt;/h2&gt;
&lt;p&gt;I use &lt;a href="https://uptimerobot.com/?rid=00d45ebb1a8a95"&gt;UptimeRobot&lt;/a&gt; to monitor the uptime of my services.
If they are down I get a nice email about it.&lt;/p&gt;
&lt;p&gt;At the time of this writing they give 50 monitors with a 5 minute period for free, which is more than enough for my personal use.&lt;/p&gt;
&lt;p&gt;If you want to try it out consider using my &lt;a href="https://uptimerobot.com/?rid=00d45ebb1a8a95"&gt;referral link&lt;/a&gt;, I'd appreciate it.&lt;/p&gt;
&lt;h2 id="the-future"&gt;The future&lt;/h2&gt;
&lt;p&gt;There are many things I'd like to do, and too little time to do them:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Monitoring and alerting for both the home server and the VPS.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/pi-hole/pi-hole"&gt;Pihole&lt;/a&gt; in the home server.&lt;/li&gt;
&lt;li&gt;Update Nextcloud setup to use &lt;a href="https://github.com/nextcloud/all-in-one"&gt;Nextcloud All-in-One&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.wireguard.com/"&gt;Wireguard&lt;/a&gt; VPN to tunnel to the home network from anywhere.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/paperless-ngx/paperless-ngx/"&gt;Paperless NGX&lt;/a&gt; in the home server.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://jellyfin.org/"&gt;Jellyfin&lt;/a&gt; in the home server for media access for the family.&lt;/li&gt;
&lt;li&gt;Set up &lt;a href="https://github.com/fail2ban/fail2ban"&gt;fail2ban&lt;/a&gt; for as many services as possible.&lt;/li&gt;
&lt;li&gt;Set up game emulators in the media player.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Rest assured there will be new articles as these items get knocked down.&lt;/p&gt;</content><category term="Software"/><category term="backup"/><category term="caddy"/><category term="debian"/><category term="docker"/><category term="free-software"/><category term="homelab"/><category term="linux"/><category term="kodi"/><category term="nextcloud"/><category term="nginx"/><category term="privacy"/><category term="self-hosting"/></entry><entry><title>Migrating to fish shell</title><link href="https://blog.fidelramos.net/software/fish-shell" rel="alternate"/><published>2023-12-07T00:00:00+00:00</published><updated>2023-12-19T00:00:00+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2023-12-07:/software/fish-shell</id><summary type="html">&lt;p&gt;I'm a heavy shell user.
As a programmer I enjoy the power and flexibility of the shell, so I always keep multiple terminals open (just one keystroke away thanks to &lt;a href="https://apps.kde.org/yakuake/"&gt;Yakuake&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;I have been using &lt;a href="https://www.gnu.org/software/bash/"&gt;Bash&lt;/a&gt; for as long as I have been a GNU/Linux user, that's 24 years …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I'm a heavy shell user.
As a programmer I enjoy the power and flexibility of the shell, so I always keep multiple terminals open (just one keystroke away thanks to &lt;a href="https://apps.kde.org/yakuake/"&gt;Yakuake&lt;/a&gt;).&lt;/p&gt;
&lt;p&gt;I have been using &lt;a href="https://www.gnu.org/software/bash/"&gt;Bash&lt;/a&gt; for as long as I have been a GNU/Linux user, that's 24 years as of the time of this writing.
Back then I didn't think twice about my shell of choice, it was the default and it was much better than what I was used to in Windows and MS-DOS.&lt;/p&gt;
&lt;p&gt;With time I discovered that there were other shells and that they were better than &lt;em&gt;Bash&lt;/em&gt;.
I gave &lt;a href="https://www.zsh.org/"&gt;zsh&lt;/a&gt; a good try, with &lt;a href="https://ohmyz.sh/"&gt;oh-my-zsh&lt;/a&gt; of course, but it didn't stick.
I was too used to &lt;em&gt;Bash&lt;/em&gt; and didn't want the hassle of installing a non-default shell.&lt;/p&gt;
&lt;p&gt;Fast-forward to early 2023, I heard news that &lt;a href="https://fishshell.com/"&gt;fish shell&lt;/a&gt; was &lt;a href="https://github.com/fish-shell/fish-shell/pull/9512"&gt;going to rewrite the full project in Rust&lt;/a&gt;.
&lt;a href="https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/"&gt;Full rewrites are dangerous&lt;/a&gt;, but sometimes they are the way to go.
I believe this is the case here: the project is of manageable size so the rewrite shouldn't take that long; it will bring a breath of fresh air and new contributors.&lt;/p&gt;
&lt;p&gt;A few months later, the &lt;a href="https://github.com/fish-shell/fish-shell/discussions/10123"&gt;rewrite is almost done&lt;/a&gt;.
This is great news, it tells me the project is healthy with an enthusiastic community and maintainers.
Reading through the &lt;a href="https://news.ycombinator.com/item?id=38423908"&gt;comments on Hacker News&lt;/a&gt; I see a lot of people loving &lt;em&gt;fish&lt;/em&gt; shell, so I decide to read up more about it.&lt;/p&gt;
&lt;p&gt;As I start seriously considering switching to &lt;em&gt;fish&lt;/em&gt; as my main shell one thing really jumps at me: &lt;em&gt;fish&lt;/em&gt; is &lt;strong&gt;not&lt;/strong&gt; POSIX-compatible.
Do I really want to learn a new syntax?
Rewrite my existing functions and scripts?
I take a look at their documentation and I really like what I see: modern &lt;em&gt;sane&lt;/em&gt; syntax.
Scripts are actually clean and readable.
Yes, learning new syntax and updating some configuration will be some effort, but I can enjoy it for the rest of my life.
The words "sunk cost fallacy" cross my mind, and I decide to make the jump.&lt;/p&gt;
&lt;p&gt;In this article I will highlight the features that sold me on &lt;em&gt;fish&lt;/em&gt;, go over details on how I ported my &lt;em&gt;Bash&lt;/em&gt; configuration, mention which plugins I use and share some lessons I learned along the way and the gotchas that bit me.&lt;/p&gt;
&lt;div class="toc"&gt;&lt;span class="toctitle"&gt;Table of Contents&lt;/span&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#features"&gt;Features&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#autocomplete"&gt;Autocomplete&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#multiline-autoindent-syntax-highlighting"&gt;Multiline, autoindent, syntax highlighting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#colors-themes-and-prompts"&gt;Colors, themes and prompts&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#and-the-list-goes-on"&gt;And the list goes on&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#porting-my-bash-configuration"&gt;Porting my Bash configuration&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#porting-environment-exports"&gt;Porting environment exports&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#porting-aliases"&gt;Porting aliases&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#porting-functions"&gt;Porting functions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#plugins"&gt;Plugins&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#pure-prompt"&gt;pure prompt&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#fzffish"&gt;fzf.fish&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#foreign-env"&gt;foreign-env&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#vendoring-third-party-plugins"&gt;Vendoring third-party plugins&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#lessons-learned"&gt;Lessons learned&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#lesson-1-function-autoloading"&gt;Lesson 1: function autoloading&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#lesson-2-error-handling-with-andor-chaining"&gt;Lesson 2: error handling with and/or chaining&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#lesson-3-string-built-in"&gt;Lesson 3: string built-in&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#lesson-4-globbing"&gt;Lesson 4: globbing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#lesson-5-variable-defaults"&gt;Lesson 5: variable defaults&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#lesson-6-array-variables-and-slices"&gt;Lesson 6: array variables and slices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#lesson-7-keybindings"&gt;Lesson 7: keybindings&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#gotchas"&gt;Gotchas&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#sourcing-etcprofile"&gt;Sourcing /etc/profile&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#closing-words"&gt;Closing words&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#updates"&gt;Updates&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h2 id="features"&gt;Features&lt;/h2&gt;
&lt;p&gt;I'm not going to go through all of &lt;em&gt;fish&lt;/em&gt; features, their &lt;a href="https://fishshell.com/docs/current/tutorial.html"&gt;tutorial&lt;/a&gt; does a good job at that.&lt;/p&gt;
&lt;p&gt;In particular I'm not going to mention the syntax, but it's so much cleaner and readable, it's day and night compared with a POSIX shell like &lt;em&gt;Bash&lt;/em&gt;.
For example check the &lt;code&gt;if&lt;/code&gt;, &lt;code&gt;for&lt;/code&gt; and &lt;code&gt;switch&lt;/code&gt; syntax.
I also encourage you to compare the &lt;em&gt;Bash&lt;/em&gt; and &lt;em&gt;fish&lt;/em&gt; versions of some of my custom functions side-by-side (more on that in their dedicated section below).&lt;/p&gt;
&lt;p&gt;Here I will focus on features that set &lt;em&gt;fish&lt;/em&gt; apart and sold me on it.&lt;/p&gt;
&lt;h3 id="autocomplete"&gt;Autocomplete&lt;/h3&gt;
&lt;p&gt;You get a lot out of &lt;em&gt;fish&lt;/em&gt; without writing any configuration.
For example the out-of-the-box autocomplete experience is amazing.
I encourage you to give it a try by just installing &lt;em&gt;fish&lt;/em&gt; and running it, no configuration necessary!&lt;/p&gt;
&lt;p&gt;Autocompleting commands:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/migrating_fish_shell/autocomplete_command.webp" title='"Click to see full size'&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/migrating_fish_shell/autocomplete_command.webp" title="Click to see full size"/&gt;&lt;figcaption&gt;fish autocompletion for commands&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Autocompleting arguments, with help extracted from man pages!&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/migrating_fish_shell/autocomplete_args.webp" title='"Click to see full size'&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/migrating_fish_shell/autocomplete_args.webp" title="Click to see full size"/&gt;&lt;figcaption&gt;fish autocompletion for arguments&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;h3 id="multiline-autoindent-syntax-highlighting"&gt;Multiline, autoindent, syntax highlighting&lt;/h3&gt;
&lt;p&gt;Another great feature is the &lt;a href="https://fishshell.com/docs/current/interactive.html#multiline-editing"&gt;multiline support&lt;/a&gt; in interactive mode, with &lt;a href="https://fishshell.com/docs/current/cmds/fish_indent.html"&gt;automatic indent&lt;/a&gt; and &lt;a href="https://fishshell.com/docs/current/tutorial.html#syntax-highlighting"&gt;syntax highlightning&lt;/a&gt;:&lt;/p&gt;
&lt;p&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/migrating_fish_shell/multiline_autoindent.webp"/&gt;&lt;figcaption&gt;Multiline edit with autoindent and syntax highlightning&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;When bringing back a multiline command from history I can press &lt;em&gt;Ctrl+e&lt;/em&gt; to move around it and edit it in the shell, or &lt;em&gt;Alt+e&lt;/em&gt; to open it in my editor.
Oh and &lt;code&gt;fish_indent&lt;/code&gt; also automatically formats my fish scripts in Emacs when using &lt;a href="https://github.com/wwwjfy/emacs-fish"&gt;fish-mode&lt;/a&gt;.
&lt;em&gt;Nice!&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;It's worth mentioning that the syntax highlighting is not just the colors of functions, variables, strings, etc., but it also marks errors in red.
Quoting &lt;a href="https://fishshell.com/docs/current/interactive.html#syntax-highlighting"&gt;the documentation&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;Detected errors include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Non-existing commands.&lt;/li&gt;
&lt;li&gt;Reading from or appending to a non-existing file.&lt;/li&gt;
&lt;li&gt;Incorrect use of output redirects&lt;/li&gt;
&lt;li&gt;Mismatched parenthesis&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;
&lt;h3 id="colors-themes-and-prompts"&gt;Colors, themes and prompts&lt;/h3&gt;
&lt;p&gt;Just run &lt;code&gt;fish_config&lt;/code&gt; and you will get a nice web UI to interactively choose theme, change individual colors, change the prompt, browse through all defined functions, browse variables, search the history and remove individual records, and see and edit keybindings.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/migrating_fish_shell/fish_config_web.webp" title='"Click to see full size'&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/migrating_fish_shell/fish_config_web.webp" title="Click to see full size"/&gt;&lt;figcaption&gt;fish_config web interface&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;Don't like the web?
&lt;code&gt;fish_config theme show&lt;/code&gt; will display available themes, with a preview:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/migrating_fish_shell/fish_config_theme_show.webp" title='"Click to see full size'&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/migrating_fish_shell/fish_config_theme_show.webp" title="Click to see full size"/&gt;&lt;figcaption&gt;fish_config theme show&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;fish_config theme choose &amp;lt;theme&amp;gt;&lt;/code&gt; will set the theme you want in the current session.
&lt;code&gt;fish_config theme save&lt;/code&gt; will save the current selection as universal variables, applied on next startup.&lt;/p&gt;
&lt;p&gt;Analogously &lt;code&gt;fish_config prompt show&lt;/code&gt; will display different available prompts, &lt;code&gt;fish_config prompt choose &amp;lt;prompt&amp;gt;&lt;/code&gt; will apply to current session, and &lt;code&gt;fish_config prompt save&lt;/code&gt; will save the current configuration as universal variables.&lt;/p&gt;
&lt;p&gt;See the full documentation for &lt;a href="https://fishshell.com/docs/current/cmds/fish_config.html"&gt;fish_config&lt;/a&gt;.&lt;/p&gt;
&lt;h3 id="and-the-list-goes-on"&gt;And the list goes on&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://fishshell.com/docs/current/interactive.html#searchable-command-history"&gt;History search&lt;/a&gt; with pagination and individual word search.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://fishshell.com/docs/current/interactive.html#id13"&gt;Directory history&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Expandable programmable &lt;a href="https://fishshell.com/docs/current/interactive.html#abbreviations"&gt;abbreviations&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://fishshell.com/docs/current/interactive.html#private-mode"&gt;Private mode&lt;/a&gt; which doesn't display past history nor records any.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://fishshell.com/docs/current/cmds/argparse.html"&gt;Advanced argument parsing&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://fishshell.com/docs/current/language.html#debugging-fish-scripts"&gt;Debugging support&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://fishshell.com/docs/current/language.html#event-handlers"&gt;Event handlers&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="porting-my-bash-configuration"&gt;Porting my Bash configuration&lt;/h2&gt;
&lt;p&gt;My full configuration is at my &lt;a href="https://github.com/haplo/dotfiles"&gt;dotfiles repository&lt;/a&gt;.
&lt;em&gt;Fish&lt;/em&gt; configuration is in &lt;a href="https://github.com/haplo/dotfiles/tree/master/.config/fish"&gt;.config/fish/&lt;/a&gt;.
It respects the &lt;a href="https://wiki.archlinux.org/title/XDG_Base_Directory"&gt;XDG Base Directory Specification&lt;/a&gt;, which is another thing I like about &lt;em&gt;fish&lt;/em&gt; shell.&lt;/p&gt;
&lt;p&gt;My &lt;em&gt;Bash&lt;/em&gt; configuration was nothing terribly complicated, mostly some &lt;a href="https://github.com/haplo/dotfiles/blob/e24ef4d8d947cd40360223d90542873f1931c6bf/.aliases"&gt;aliases&lt;/a&gt;, &lt;a href="https://github.com/haplo/dotfiles/blob/e24ef4d8d947cd40360223d90542873f1931c6bf/.exports"&gt;exports&lt;/a&gt; and &lt;a href="https://github.com/haplo/dotfiles/blob/e24ef4d8d947cd40360223d90542873f1931c6bf/.functions"&gt;functions&lt;/a&gt;, plus redefining the prompt, enabling autocompletion...&lt;/p&gt;
&lt;p&gt;Porting to &lt;em&gt;fish&lt;/em&gt; meant learning the new syntax and features.
Most of the time reading the fine &lt;a href="https://fishshell.com/docs/current/index.html"&gt;fish documentation&lt;/a&gt; was enough to get me on my way.
Special kudos for their &lt;a href="https://fishshell.com/docs/current/fish_for_bash_users.html"&gt;fish for Bash users doc&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;My porting process can be broken down like this:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Port environment exports.&lt;/li&gt;
&lt;li&gt;Port aliases.&lt;/li&gt;
&lt;li&gt;Port functions.&lt;/li&gt;
&lt;li&gt;Add plugins.&lt;/li&gt;
&lt;li&gt;Adjust my &lt;code&gt;init_dotfiles&lt;/code&gt; script.&lt;/li&gt;
&lt;li&gt;Script to update vendored plugins.&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 id="porting-environment-exports"&gt;Porting environment exports&lt;/h3&gt;
&lt;p&gt;This was fairly straightforward, just used &lt;code&gt;set -gx VAR value&lt;/code&gt; in &lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/fish/conf.d/exports.fish"&gt;&lt;code&gt;~/.config/fish/conf.d/exports.fish&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Some exports I like to do conditionally, so the configuration works on a variety of systems that might not have my favored tools (e.g. prefer &lt;code&gt;most&lt;/code&gt; over &lt;code&gt;less&lt;/code&gt;, or &lt;code&gt;exa/eza&lt;/code&gt; over &lt;code&gt;ls&lt;/code&gt;).&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt; -q most
    &lt;span class="k"&gt;set&lt;/span&gt; -gx PAGER most
    &lt;span class="k"&gt;set&lt;/span&gt; -gx MANPAGER most
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt; -q less
    &lt;span class="k"&gt;set&lt;/span&gt; -gx PAGER less
    &lt;span class="c"&gt;# don’t clear the screen after quitting a manual page&lt;/span&gt;
    &lt;span class="k"&gt;set&lt;/span&gt; -gx MANPAGER &lt;span class="s1"&gt;'less -X'&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;I will probably end up moving many of these global exports to universal variables installed by &lt;code&gt;init_dotfiles&lt;/code&gt; script.
More on that below.&lt;/p&gt;
&lt;h3 id="porting-aliases"&gt;Porting aliases&lt;/h3&gt;
&lt;p&gt;Initially I ported my &lt;a href="https://github.com/haplo/dotfiles/blob/c4ce95ba9902aee856811df5902644e12adf0821/.aliases"&gt;&lt;em&gt;Bash&lt;/em&gt; aliases&lt;/a&gt; using &lt;em&gt;fish&lt;/em&gt; &lt;code&gt;alias&lt;/code&gt; and didn't think twice about it.
Turns out that &lt;a href="https://fishshell.com/docs/current/language.html#defining-aliases"&gt;&lt;em&gt;fish&lt;/em&gt; aliases become functions under the hood&lt;/a&gt;, i.e. &lt;em&gt;fish&lt;/em&gt; &lt;code&gt;alias&lt;/code&gt; is just a wrapper for creating functions.&lt;/p&gt;
&lt;p&gt;I discovered that &lt;em&gt;fish&lt;/em&gt; has something called &lt;a href="https://fishshell.com/docs/current/cmds/abbr.html"&gt;abbreviations&lt;/a&gt;, which get expanded into their full form after pressing &lt;em&gt;Space&lt;/em&gt; or &lt;em&gt;Enter&lt;/em&gt;.
This has a number of benefits:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Faster than functions.&lt;/li&gt;
&lt;li&gt;Commands appear in full in history.&lt;/li&gt;
&lt;li&gt;Can edit after expansion.&lt;/li&gt;
&lt;li&gt;Can optionally be expanded anywhere in the prompt, not just at the beginning.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I &lt;a href="https://github.com/haplo/dotfiles/commit/c988edae4c93dfa3804dc78b569881a085f4d941"&gt;rewrote all my aliases into abbreviations&lt;/a&gt;, as they were all command expansions, appearing at the beginning of the prompt.&lt;/p&gt;
&lt;p&gt;A &lt;em&gt;fish&lt;/em&gt; abbreviation can be configured to appear anywhere in the prompt by using &lt;code&gt;--position anywhere&lt;/code&gt;.
I'm not using this feature yet but will be on the lookout to see where it will come in handy.&lt;/p&gt;
&lt;p&gt;Oh and if you want to keep &lt;em&gt;fish&lt;/em&gt; from expanding an abbreviation just press &lt;em&gt;Ctrl+Space&lt;/em&gt;.&lt;/p&gt;
&lt;h3 id="porting-functions"&gt;Porting functions&lt;/h3&gt;
&lt;p&gt;I have a few &lt;a href="https://github.com/haplo/dotfiles/blob/b9887e4b1525d08f8b9a4cf6a442e70a00bafa38/.functions"&gt;Bash functions&lt;/a&gt; for some repetitive tasks.
I could have extracted them into separate scripts with &lt;code&gt;#!/bin/bash&lt;/code&gt; shebangs and placed somewhere in &lt;code&gt;$PATH&lt;/code&gt; to keep executing them same as before.
But I wanted to give &lt;em&gt;fish&lt;/em&gt; a test ride, so I decided to rewrite them.
And boy was I glad I did!
Just this little practice made me feel comfortable with &lt;em&gt;fish&lt;/em&gt; syntax and I learned some valuable lessons by reading the documentation.&lt;/p&gt;
&lt;p&gt;These are my ported functions as of the time of this writing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/fish/functions/cbr2cbz.fish"&gt;cbr2cbz&lt;/a&gt;: to transform digital comic books in &lt;a href="https://en.wikipedia.org/wiki/Comic_book_archive"&gt;CBR format to CBZ&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/fish/functions/mkd.fish"&gt;mkd&lt;/a&gt;: create a directory (with arbitrary level of nesting) and &lt;em&gt;cd&lt;/em&gt; into it.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/fish/functions/mts2mkv.fish"&gt;mts2mkv&lt;/a&gt;: transform MTS video files into MKV. Some of my digital cameras create MTS videos.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/fish/functions/nds.fish"&gt;nds&lt;/a&gt;: get a &lt;a href="https://nodejs.org/"&gt;NodeJS&lt;/a&gt; shell inside a &lt;a href="https://podman.io/"&gt;Podman&lt;/a&gt; container. I don't trust &lt;em&gt;npm&lt;/em&gt; installing gigabytes of unvetted dependencies in my systems.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/fish/functions/pdf2cbz.fish"&gt;pdf2cbz&lt;/a&gt;: transform PDF into CBZ, meant for digital comic books. Internally uses &lt;code&gt;pdfextractimages&lt;/code&gt; function.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/fish/functions/pdfextractimages.fish"&gt;pdfextractimages&lt;/a&gt;: extract all JPEG and PNG images from a PDF.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/fish/functions/splitflac.fish"&gt;splitflac&lt;/a&gt;: takes music in the form of a single big FLAC file + a CUE file and creates separate FLAC files for each track, with proper tags.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/fish/functions/urldecode.fish"&gt;urldecode&lt;/a&gt; and &lt;a href="https://github.com/haplo/dotfiles/blob/master/.config/fish/functions/urlencode.fish"&gt;urlencode&lt;/a&gt;: simple convenience functions to encode and decode URLs.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I'm still a fledgling &lt;em&gt;fish&lt;/em&gt; user, so some bits might not be idiomatic.
If you want to help me improve them, pull requests are welcome!&lt;/p&gt;
&lt;h3 id="plugins"&gt;Plugins&lt;/h3&gt;
&lt;p&gt;One of the things I like about &lt;em&gt;fish&lt;/em&gt; is that the default experience is already great with zero configuration.
That means if I ever found myself in a fresh or strange environment it wouldn't differ much from my custom setup.
But also that I would spend less time customizing.
I already spend too much time bikeshedding &lt;a href="https://github.com/haplo/dotemacs"&gt;my Emacs&lt;/a&gt;. 😁&lt;/p&gt;
&lt;p&gt;Nonetheless there are some great plugins that are worth installing.
&lt;a href="https://github.com/jorgebucaran/awsm.fish"&gt;awsm.fish&lt;/a&gt; has a curated list of plugins and other &lt;em&gt;fish&lt;/em&gt; resources.&lt;/p&gt;
&lt;p&gt;In my case I narrowed it down to three plugins, as I like to keep things simple:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/pure-fish/pure"&gt;pure prompt&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/PatrickF1/fzf.fish"&gt;fzf.fish&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/oh-my-fish/plugin-foreign-env"&gt;foreign-env&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 id="pure-prompt"&gt;pure prompt&lt;/h4&gt;
&lt;p&gt;I considered the three main prompt plugins for fish:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/IlanCosman/tide"&gt;tide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/pure-fish/pure/"&gt;pure&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/jorgebucaran/hydro"&gt;hydro&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I liked &lt;em&gt;tide&lt;/em&gt; the most, but to minimize dependencies and simplify setup for now I settled on &lt;em&gt;pure&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;I will probably review this decision in the future.&lt;/p&gt;
&lt;h4 id="fzffish"&gt;fzf.fish&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://github.com/junegunn/fzf"&gt;fzf&lt;/a&gt; is seriously amazing, a project that I can recommend hands down.
I have been using it with &lt;em&gt;Bash&lt;/em&gt; for a while now, and it's the first integration I look for.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/PatrickF1/fzf.fish"&gt;fzf.fish&lt;/a&gt; is the best fzf plugin for fish.
It allows you to:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/PatrickF1/fzf.fish#-search-directory"&gt;Search the current directory&lt;/a&gt; (recursively), with preview of the files as you scroll.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/PatrickF1/fzf.fish#-search-git-log"&gt;Search in git log&lt;/a&gt;, with preview of the commits.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/PatrickF1/fzf.fish#-search-git-status"&gt;Search git status&lt;/a&gt;, with diff of the changes of each file.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/PatrickF1/fzf.fish#-search-history"&gt;Search shell history&lt;/a&gt;, improving upon fish's already good search.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/PatrickF1/fzf.fish#%EF%B8%8F-search-processes"&gt;Search processes&lt;/a&gt;, showing details in the preview area.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/PatrickF1/fzf.fish#-search-variables"&gt;Search variables&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I'm purposefully not including screenshots of &lt;em&gt;fzf.fish&lt;/em&gt; as its README already includes a video and screenshots, do take a look at them.&lt;/p&gt;
&lt;h4 id="foreign-env"&gt;foreign-env&lt;/h4&gt;
&lt;p&gt;&lt;a href="https://github.com/oh-my-fish/plugin-foreign-env"&gt;foreign-env&lt;/a&gt; is a little plugin that runs a &lt;em&gt;Bash&lt;/em&gt; script and imports defined variables in &lt;em&gt;fish&lt;/em&gt;.
It calls &lt;em&gt;Bash&lt;/em&gt; internally, so it's not just a parser.&lt;/p&gt;
&lt;p&gt;I use it to source /etc/profile so I can make &lt;em&gt;fish&lt;/em&gt; my default shell, more details about that in the &lt;em&gt;Gotchas&lt;/em&gt; section below.&lt;/p&gt;
&lt;h4 id="vendoring-third-party-plugins"&gt;Vendoring third-party plugins&lt;/h4&gt;
&lt;p&gt;Plugin managers like &lt;a href="https://github.com/jorgebucaran/fisher"&gt;fisher&lt;/a&gt; or &lt;a href="https://github.com/oh-my-fish/oh-my-fish"&gt;oh-my-fish&lt;/a&gt; are a convenient way of installing plugins, but the problem is that they download and run unsigned code.
This is something that I avoid doing as much as possible, as it's a big security hole.&lt;/p&gt;
&lt;p&gt;For my fish configuration I decided to vendor them, i.e. include a full copy inside my configuration.
This way I have better assurances about the code that my computers will be running.
This is particularly important for the shell, even more when I aim to make it my global default.&lt;/p&gt;
&lt;p&gt;I put the plugins code in a &lt;a href="https://github.com/haplo/dotfiles/tree/master/.config/fish/vendor"&gt;dedicated vendor directory&lt;/a&gt;.
Then my &lt;a href="https://github.com/haplo/dotfiles/blob/b9887e4b1525d08f8b9a4cf6a442e70a00bafa38/init_dotfiles.sh#L15-L17"&gt;init_dotfiles script&lt;/a&gt; copies the vendor files into the right locations. I could have done without the &lt;em&gt;vendor&lt;/em&gt; directory altogether, but I think keeping them separate is more respectful of the original creators.&lt;/p&gt;
&lt;p&gt;The second part is to keep the plugins up to date.
I wrote the &lt;a href="https://github.com/haplo/dotfiles/blob/master/update_vendor.fish"&gt;update_vendor.fish script&lt;/a&gt; that I will run from time to time.
It downloads the latest version of the plugins I use and puts the files in my &lt;a href="https://github.com/haplo/dotfiles/tree/master/.config/fish/vendor"&gt;vendor directory&lt;/a&gt;.
If there are any changes I can review them before committing them to my repository.&lt;/p&gt;
&lt;h2 id="lessons-learned"&gt;Lessons learned&lt;/h2&gt;
&lt;p&gt;In the process of moving my &lt;em&gt;Bash&lt;/em&gt; configuration to &lt;em&gt;fish&lt;/em&gt; and reading through their documentation I learned some things I think are useful and not immediately obvious.
I will share them here in hope that they may be helpful to other people looking to migrate to &lt;em&gt;fish&lt;/em&gt;.&lt;/p&gt;
&lt;h3 id="lesson-1-function-autoloading"&gt;Lesson 1: function autoloading&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Fish&lt;/em&gt; &lt;a href="https://fishshell.com/docs/current/tutorial.html#autoloading-functions"&gt;autoloads functions&lt;/a&gt; when needed.
That means even if you have hundreds of files in your &lt;code&gt;~/.config/fish/functions&lt;/code&gt; directory, they won't all be eagerly loaded when a shell starts, but only when you invoke a function with a name that matches the file name.&lt;/p&gt;
&lt;p&gt;A function file can still define multiple functions, but it will only get loaded when the exact name &lt;strong&gt;of the file&lt;/strong&gt; is invoked in a shell.&lt;/p&gt;
&lt;h3 id="lesson-2-error-handling-with-andor-chaining"&gt;Lesson 2: error handling with and/or chaining&lt;/h3&gt;
&lt;p&gt;&lt;a href="https://fishshell.com/docs/current/language.html#combiners-and-or"&gt;&lt;code&gt;and&lt;/code&gt; and &lt;code&gt;or&lt;/code&gt; combiners&lt;/a&gt; are a neat way of handling command successes and errors.
For example:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;tar xvf file.tgz
&lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Extracted!"&lt;/span&gt;
&lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Error!"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; 1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;However the &lt;em&gt;fish&lt;/em&gt; documentation advises not to overuse chaining with &lt;code&gt;and&lt;/code&gt; and &lt;code&gt;or&lt;/code&gt;, as it can lead to wrong code paths being taken.
Instead it recommends using &lt;code&gt;if&lt;/code&gt; even if a bit more verbose:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; tar xvzf file.tgz
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Extracted!"&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Error!"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; 1
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;h3 id="lesson-3-string-built-in"&gt;Lesson 3: string built-in&lt;/h3&gt;
&lt;p&gt;The &lt;a href="https://fishshell.com/docs/current/cmds/string.html#match-subcommand"&gt;&lt;code&gt;string&lt;/code&gt; builtin&lt;/a&gt; is powerful, ergonomic and readable, worth learning and using.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://fishshell.com/docs/current/cmds/string.html#match-subcommand"&gt;&lt;code&gt;string match&lt;/code&gt;&lt;/a&gt; has both simple globbing syntax or Perl-compatible regexes (I wonder if the syntax will change to Rust's regexes after the rewrite).
It can capture groups, ignore case, do partial or entire string match...
An example from my &lt;code&gt;pdfextractimages&lt;/code&gt; function:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c"&gt;# check that $pdffile ends in .pdf&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; string match -q -i -e &lt;span class="s1"&gt;'*.pdf'&lt;/span&gt; &lt;span class="nv"&gt;$pdffile&lt;/span&gt;
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pdffile&lt;/span&gt;&lt;span class="s2"&gt; doesn't seem to be a PDF file"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; 3
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href="https://fishshell.com/docs/current/cmds/string.html#split-and-split0-subcommands"&gt;&lt;code&gt;string split&lt;/code&gt;&lt;/a&gt; is a nice replacement for &lt;code&gt;cut&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; string split . example.com
example
com

&lt;span class="c"&gt;# one single split on the right&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; string split -r -m1 / /usr/local/bin/fish
/usr/local/bin
&lt;span class="nb"&gt;fish&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;&lt;a href="https://fishshell.com/docs/current/cmds/string.html#replace-subcommand"&gt;&lt;code&gt;string replace&lt;/code&gt;&lt;/a&gt; is featureful and clean, can do simple string replacement or regex, including capturing groups:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c"&gt;# simple substitution blue -&amp;gt; red, first appearance only!&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; string replace blue red &lt;span class="s1"&gt;'blue is my favorite blue'&lt;/span&gt;
red is my favorite blue

&lt;span class="c"&gt;# substitute all appearances with -a&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; string replace -a blue red &lt;span class="s1"&gt;'blue is my favorite blue'&lt;/span&gt;
red is my favorite red

&lt;span class="c"&gt;# replace .pdf extension with .cbz using a regex&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; string replace -r &lt;span class="s1"&gt;'.pdf$'&lt;/span&gt; .cbz somedocument.pdf
somedocument.cbz
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;I found this idiom with &lt;code&gt;string replace --filter ...&lt;/code&gt; where it first attempts to match input with the given pattern.
I pair that with &lt;code&gt;and&lt;/code&gt; and &lt;code&gt;or&lt;/code&gt; for error control.
For example in my &lt;code&gt;cbr2cbz&lt;/code&gt; function I replace the &lt;em&gt;.cbr&lt;/em&gt; extension with &lt;em&gt;.cbz&lt;/em&gt; and at the same time detect if the input is invalid:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;set&lt;/span&gt; cbzfile &lt;span class="o"&gt;(&lt;/span&gt;string replace -f -r &lt;span class="s1"&gt;'.cbr$'&lt;/span&gt; .cbz &lt;span class="nv"&gt;$cbrfile&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$cbrfile&lt;/span&gt;&lt;span class="s2"&gt; doesn't seem to be a .cbr file"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; 2
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;One last example is that &lt;code&gt;string escape&lt;/code&gt; and &lt;code&gt;string unescape&lt;/code&gt; support multiple styles, so I was able to &lt;a href="https://github.com/haplo/dotfiles/commit/820dd0b21dcb9e39c0ee72a382cbc29b54ba3315"&gt;refactor my urlencode and urldecode functions&lt;/a&gt; to drop their Python dependency.&lt;/p&gt;
&lt;h3 id="lesson-4-globbing"&gt;Lesson 4: globbing&lt;/h3&gt;
&lt;p&gt;An important difference with &lt;em&gt;Bash&lt;/em&gt; and other shells is &lt;a href="https://fishshell.com/docs/current/fish_for_bash_users.html#wildcards-globs"&gt;globbing behavior&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;An empty glob results in an error in &lt;em&gt;fish&lt;/em&gt; when used on a command, but not with &lt;code&gt;set&lt;/code&gt;, &lt;code&gt;for&lt;/code&gt; or &lt;code&gt;count&lt;/code&gt;.
This is often a good thing to avoid gotchas.&lt;/p&gt;
&lt;p&gt;For example this code was failing sometimes because the glob was empty (no images to zip):&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;zip -9 --move --junk-paths --quiet &lt;span class="nv"&gt;$cbzfile&lt;/span&gt; &lt;span class="nv"&gt;$tmpdir&lt;/span&gt;/*.&lt;span class="err"&gt;{&lt;/span&gt;jpg,JPG,jpeg,JPEG,png,PNG&lt;span class="err"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;I rewrote it to use &lt;code&gt;set&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;set&lt;/span&gt; images &lt;span class="nv"&gt;$tmpdir&lt;/span&gt;/*.&lt;span class="err"&gt;{&lt;/span&gt;jpg,JPG,jpeg,JPEG,png,PNG&lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="k"&gt;test&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="k"&gt;count&lt;/span&gt; &lt;span class="nv"&gt;$images&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; -eq 0
    &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No images extracted, stopping"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; 7
&lt;span class="k"&gt;end&lt;/span&gt;

zip -9 --move --junk-paths --quiet &lt;span class="nv"&gt;$cbzfile&lt;/span&gt; &lt;span class="nv"&gt;$images&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Also of note is that &lt;em&gt;fish&lt;/em&gt; &lt;a href="https://fishshell.com/docs/current/language.html#wildcards-globbing"&gt;deprecated the &lt;code&gt;?&lt;/code&gt; single-character glob&lt;/a&gt;, and will disable it in the future.
I like it because it makes working with URLs in the shell easier.
In the same vein I like that &lt;em&gt;fish&lt;/em&gt; only treats &lt;code&gt;&amp;amp;&lt;/code&gt; as the special character to &lt;a href="https://fishshell.com/docs/current/language.html#job-control"&gt;run a program in background&lt;/a&gt; if it's followed by a non-separating character.
No more having &lt;code&gt;wget&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt; or &lt;code&gt;yt-dlp&lt;/code&gt; run as a background job because I forgot to quote the URL.&lt;/p&gt;
&lt;h3 id="lesson-5-variable-defaults"&gt;Lesson 5: variable defaults&lt;/h3&gt;
&lt;p&gt;There is no way in &lt;em&gt;fish&lt;/em&gt; to set a default value for a variable, something that can come in handy when defining functions.&lt;/p&gt;
&lt;p&gt;Currently I use code like this:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;set&lt;/span&gt; output &lt;span class="nv"&gt;$argv&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;2&lt;span class="o"&gt;]&lt;/span&gt;
&lt;span class="c"&gt;# default to current directory&lt;/span&gt;
&lt;span class="k"&gt;test&lt;/span&gt; -z &lt;span class="nv"&gt;$output&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; output .
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;h3 id="lesson-6-array-variables-and-slices"&gt;Lesson 6: array variables and slices&lt;/h3&gt;
&lt;p&gt;&lt;em&gt;Bash&lt;/em&gt; supports &lt;a href="https://www.gnu.org/software/bash/manual/html_node/Arrays.html"&gt;arrays&lt;/a&gt;, both index-based and associative.
But the syntax is ugly and hard to remember.
Access elements with &lt;code&gt;${array[i]}&lt;/code&gt;, instead of just &lt;code&gt;$array[i]&lt;/code&gt;.
Count the number of elements with &lt;code&gt;${#array[@]}&lt;/code&gt; (notice the &lt;code&gt;#&lt;/code&gt;?).
I rarely used arrays in &lt;em&gt;Bash&lt;/em&gt;, and when I did I had to constantly look at references.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;fish&lt;/em&gt; has &lt;a href="https://fishshell.com/docs/current/language.html#variables-lists"&gt;lists&lt;/a&gt;, which are functionally the same, but the syntax is what you would expect coming from other programming languages:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; fruit apple orange banana mango pineapple

&lt;span class="c"&gt;# indexes are 1-based&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$fruit&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;1&lt;span class="o"&gt;]&lt;/span&gt;
apple

&lt;span class="c"&gt;# negative indexing&lt;/span&gt;
❯ &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$fruit&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;-1&lt;span class="o"&gt;]&lt;/span&gt;
pineapple

&lt;span class="c"&gt;# slices&lt;/span&gt;
❯ &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$fruit&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;1..3&lt;span class="o"&gt;]&lt;/span&gt;
apple orange banana

&lt;span class="c"&gt;# reverse slices&lt;/span&gt;
❯ &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$fruit&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;3..1&lt;span class="o"&gt;]&lt;/span&gt;
banana orange apple

&lt;span class="c"&gt;# multiple indexes in one expression&lt;/span&gt;
❯ &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$fruit&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt; 2&lt;span class="o"&gt;]&lt;/span&gt;
banana pineapple orange

&lt;span class="c"&gt;# multiple slices in one expression, even with repetitions&lt;/span&gt;
❯ &lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$fruit&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;1..3 2..4&lt;span class="o"&gt;]&lt;/span&gt;
apple orange banana orange banana mango

&lt;span class="c"&gt;# number of elements&lt;/span&gt;
❯ &lt;span class="k"&gt;count&lt;/span&gt; &lt;span class="nv"&gt;$fruit&lt;/span&gt;
5
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Do note that &lt;a href="https://fishshell.com/docs/current/language.html#variables-path"&gt;path variables&lt;/a&gt; (those ending with &lt;code&gt;PATH&lt;/code&gt;) are treated especially: the elements are joined with colons when exported.
This is for compatibility with other shells and programs that might not support arrays.&lt;/p&gt;
&lt;h3 id="lesson-7-keybindings"&gt;Lesson 7: keybindings&lt;/h3&gt;
&lt;p&gt;Do read on &lt;a href="https://fishshell.com/docs/current/interactive.html#shared-bindings"&gt;fish keybindings&lt;/a&gt;, there are some hidden gems.
Some highlights:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Alt+←&lt;/em&gt; moves to the previous directory in the &lt;a href="https://fishshell.com/docs/current/interactive.html#id13"&gt;directory history&lt;/a&gt;, &lt;em&gt;Alt+→&lt;/em&gt; moves forward.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Alt+↑&lt;/em&gt; and &lt;em&gt;Alt+↓&lt;/em&gt; search through history for &lt;strong&gt;tokens&lt;/strong&gt; containing the token under cursor.
If for example you are looking for PDF files used in the history, you can type &lt;code&gt;.pdf&lt;/code&gt; and then &lt;em&gt;Alt+↑&lt;/em&gt; will search for other tokens with that component.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Alt+H&lt;/em&gt; opens the man page for the current command.
I don't know about you but I find myself opening new shells to look up man pages for the command I'm trying to use all the time.
This and &lt;em&gt;fish&lt;/em&gt; autocomplete are an invaluable help.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Alt+S&lt;/em&gt; prepends or removes &lt;code&gt;sudo&lt;/code&gt; from the current command.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Alt+O&lt;/em&gt; opens the file under cursor in the &lt;code&gt;$PAGER&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Alt+E&lt;/em&gt; or &lt;em&gt;Alt+V&lt;/em&gt; open the current prompt in the &lt;code&gt;$EDITOR&lt;/code&gt;.
Useful for editing multi-line prompts comfortably.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;There is support for both &lt;a href="https://fishshell.com/docs/current/interactive.html#emacs-mode-commands"&gt;Emacs style&lt;/a&gt; and &lt;a href="https://fishshell.com/docs/current/interactive.html#vi-mode-commands"&gt;Vi style&lt;/a&gt; modes.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;fish&lt;/em&gt; makes it easy to &lt;a href="https://fishshell.com/docs/current/interactive.html#custom-bindings"&gt;add new bindings&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="gotchas"&gt;Gotchas&lt;/h2&gt;
&lt;h3 id="sourcing-etcprofile"&gt;Sourcing /etc/profile&lt;/h3&gt;
&lt;p&gt;&lt;a href="https://github.com/fish-shell/fish-shell/issues/3665"&gt;One&lt;/a&gt; &lt;a href="https://github.com/fish-shell/fish-shell/issues/9887"&gt;common&lt;/a&gt; &lt;a href="https://github.com/sddm/sddm/issues/1059"&gt;issue&lt;/a&gt; when running &lt;em&gt;fish&lt;/em&gt; as default shell (hint: &lt;code&gt;chsh&lt;/code&gt;) is that it doesn't run &lt;em&gt;/etc/profile&lt;/em&gt; and &lt;em&gt;/etc/profile.d/*&lt;/em&gt; scripts, as they are not in &lt;em&gt;fish&lt;/em&gt; syntax.
For me this manifested in a blank screen when loading my graphical environment.
I had to get to the console, login as root and change my user's default shell back to &lt;em&gt;Bash&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;A workaround is to leave &lt;em&gt;Bash&lt;/em&gt; as default shell and run &lt;em&gt;fish&lt;/em&gt; only interactively.
One way of doing this is to add this to the end of &lt;code&gt;~/.bashrc&lt;/code&gt; (as proposed &lt;a href="https://github.com/fish-shell/fish-shell/issues/3665#issuecomment-326806107"&gt;here&lt;/a&gt;):&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PS1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;-a&lt;span class="w"&gt; &lt;/span&gt;-x&lt;span class="w"&gt; &lt;/span&gt;/bin/fish&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;exec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;fish
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;This loads up &lt;em&gt;Bash&lt;/em&gt;, but replaces the process with &lt;em&gt;fish&lt;/em&gt;.
It's inefficient but probably negligible with today's computers.
Still it leaves a bad taste in my mouth.&lt;/p&gt;
&lt;p&gt;The &lt;a href="https://wiki.archlinux.org/title/Fish#Source_/etc/profile_on_login"&gt;Archlinux Wiki on fish&lt;/a&gt; documents this way of sourcing &lt;code&gt;/etc/profile&lt;/code&gt; in login shells by using &lt;em&gt;Bash&lt;/em&gt; and then replacing it with a &lt;em&gt;fish&lt;/em&gt; process:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c"&gt;# add to ~/.config/fish/config.fish&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;status &lt;/span&gt;is-login
    &lt;span class="nb"&gt;exec &lt;/span&gt;bash -c &lt;span class="s2"&gt;"test -e /etc/profile &amp;amp;&amp;amp; source /etc/profile;\&lt;/span&gt;
&lt;span class="s2"&gt;    exec fish"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;However this was not working for me, my desktop environment was still not loading.&lt;/p&gt;
&lt;p&gt;My current solution is to use the &lt;a href="https://github.com/oh-my-fish/plugin-foreign-env"&gt;foreign-env plugin&lt;/a&gt; to load the profile:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="c"&gt;# add to ~/.config/fish/config.fish&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;status &lt;/span&gt;is-login &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;test&lt;/span&gt; -e /etc/profile
    fenv &lt;span class="nb"&gt;source&lt;/span&gt; /etc/profile
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;This works for me in Kubuntu 22.04 LTS, I now have &lt;em&gt;fish&lt;/em&gt; as my default shell, login and interactive.&lt;/p&gt;
&lt;h2 id="closing-words"&gt;Closing words&lt;/h2&gt;
&lt;p&gt;It's only been a few days with &lt;em&gt;fish&lt;/em&gt; as my main driver, but I have been pleasantly surprised by its features.
Time will tell whether I find any bumps along the road, but I get the feeling that if anything I will regret not making the switch &lt;em&gt;sooner&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;One thing that was bugging me is whether this change would be productive or worth it.
It's still early to answer that question, but I can tell you that it was &lt;strong&gt;fun&lt;/strong&gt; and &lt;strong&gt;joyful&lt;/strong&gt;, like a good vacation.
And sometimes that makes it worth it.&lt;/p&gt;
&lt;p&gt;Looking forward to that Rust rewrite!&lt;/p&gt;
&lt;p&gt;Thank you &lt;em&gt;fish&lt;/em&gt; team!&lt;/p&gt;
&lt;h2 id="updates"&gt;Updates&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;2023-12-19: Correct description of &lt;em&gt;foreign-env&lt;/em&gt; plugin, it does run &lt;em&gt;Bash&lt;/em&gt; unless originally stated. Add &lt;em&gt;Alt+S&lt;/em&gt; keybinding.&lt;/li&gt;
&lt;/ul&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="fish"/><category term="shell"/><category term="linux"/></entry><entry><title>Replacing Nginx with Caddy in Docker Compose</title><link href="https://blog.fidelramos.net/software/switch-nginx-caddy-docker-compose" rel="alternate"/><published>2023-11-09T00:00:00+00:00</published><updated>2023-11-09T15:45:12.501742+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2023-11-09:/software/switch-nginx-caddy-docker-compose</id><summary type="html">&lt;p&gt;The main server in my homelab runs a bunch of services, but the heart of it is a &lt;a href="https://docs.docker.com/compose/"&gt;Docker Compose&lt;/a&gt; configuration, several of them exposed to the Internet via a reverse proxy webserver.&lt;/p&gt;
&lt;p&gt;Before I was using three Docker images to run Nginx, proxy traffic to other services and have …&lt;/p&gt;</summary><content type="html">&lt;p&gt;The main server in my homelab runs a bunch of services, but the heart of it is a &lt;a href="https://docs.docker.com/compose/"&gt;Docker Compose&lt;/a&gt; configuration, several of them exposed to the Internet via a reverse proxy webserver.&lt;/p&gt;
&lt;p&gt;Before I was using three Docker images to run Nginx, proxy traffic to other services and have it generate Letsencrypt certificates for the necessary domains:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;a href="https://hub.docker.com/_/nginx"&gt;nginx&lt;/a&gt; with the actual Nginx server running.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/jwilder/nginx-proxy"&gt;nginx-proxy&lt;/a&gt; to generate Nginx configurations.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/jwilder/docker-letsencrypt-nginx-proxy-companion"&gt;docker-letsencrypt-nginx-proxy-companion&lt;/a&gt;: to handle LetsEncrypt certificate provisioning and renewal.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The relevant configuration in &lt;em&gt;compose.yml&lt;/em&gt; looked like this:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;nginx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;nginx&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;UFW_MANAGED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_WEB:-nginx}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;always&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"${DOCKER_HTTP:-80}:80"&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"${DOCKER_HTTPS:-443}:443"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/conf.d:/etc/nginx/conf.d&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/vhost.d:/etc/nginx/vhost.d&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/html:/usr/share/nginx/html&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/certs:/etc/nginx/certs:ro&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/htpasswd:/etc/nginx/htpasswd:ro&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_WEB_LOG_DRIVER:-json-file}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;max-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_WEB_LOG_MAX_SIZE:-4m}&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;max-file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_WEB_LOG_MAX_FILE:-10}&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;nginx-gen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;nginxproxy/docker-gen&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;-notify-sighup ${NGINX_WEB:-nginx} -watch -wait 5s:30s /etc/docker-gen/templates/nginx.tmpl /etc/nginx/conf.d/default.conf&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${DOCKER_GEN:-nginx-gen}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;always&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/conf.d:/etc/nginx/conf.d&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/vhost.d:/etc/nginx/vhost.d&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/html:/usr/share/nginx/html&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/certs:/etc/nginx/certs:ro&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/htpasswd:/etc/nginx/htpasswd:ro&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/run/docker.sock:/tmp/docker.sock:ro&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;./nginx.tmpl:/etc/docker-gen/templates/nginx.tmpl:ro&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_GEN_LOG_DRIVER:-json-file}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;max-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_GEN_LOG_MAX_SIZE:-2m}&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;max-file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_GEN_LOG_MAX_FILE:-10}&lt;/span&gt;

&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;nginx-letsencrypt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;nginxproxy/acme-companion&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${LETSENCRYPT_CONTAINER:-nginx-letsencrypt}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;always&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/conf.d:/etc/nginx/conf.d&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/vhost.d:/etc/nginx/vhost.d&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/html:/usr/share/nginx/html&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_FILES_PATH}/certs:/etc/nginx/certs:rw&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/opt/acme.sh:/etc/acme.sh&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/run/docker.sock:/var/run/docker.sock:ro&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;NGINX_DOCKER_GEN_CONTAINER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${DOCKER_GEN:-nginx-gen}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;NGINX_PROXY_CONTAINER&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_WEB:-nginx}&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;logging&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;driver&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_LETSENCRYPT_LOG_DRIVER:-json-file}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;max-size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_LETSENCRYPT_LOG_MAX_SIZE:-2m}&lt;/span&gt;
&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="nt"&gt;max-file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NGINX_LETSENCRYPT_LOG_MAX_FILE:-10}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Other services to proxy defined &lt;code&gt;VIRTUAL_HOST&lt;/code&gt; and &lt;code&gt;LETSENCRYPT_HOST&lt;/code&gt; environment variables.
For example this was my Nextcloud service configuration (I will only show the relevant parts):&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;nextcloud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;nextcloud:latest&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;nextcloud&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;VIRTUAL_HOST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NEXTCLOUD_HOST}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;LETSENCRYPT_HOST&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NEXTCLOUD_HOST}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;LETSENCRYPT_EMAIL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${LETSENCRYPT_EMAIL}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Besides the Docker compose services configuration, I also required a Nginx configuration template (that's the &lt;em&gt;nginx.tmpl&lt;/em&gt; that you can see in the configuration), and other per-service tweaks (like increasing the maximum request body size for my Nextcloud instance).&lt;/p&gt;
&lt;p&gt;I had already replaced the Nginx webserver that hosts this blog to Caddy, so I wanted to do the same with the home server.
I decided to use &lt;a href="https://github.com/lucaslorentz/caddy-docker-proxy"&gt;caddy-docker-proxy&lt;/a&gt; because it was similar to &lt;em&gt;nginx-proxy&lt;/em&gt;, so it would simplify the migration.
What I didn't expect is how much simpler it would become.&lt;/p&gt;
&lt;p&gt;The above configuration was replaced by:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;caddy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;lucaslorentz/caddy-docker-proxy:ci-alpine&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;restart&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;unless-stopped&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;ports&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"${DOCKER_HTTP:-80}:80"&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"${DOCKER_HTTPS:-443}:443"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;networks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;default&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;webproxy&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;# Global options&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy.email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${CADDY_EMAIL}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;UFW_MANAGED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;volumes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;/var/run/docker.sock:/var/run/docker.sock&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# this volume is needed to keep the certificates&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# otherwise, new ones will be re-issued upon restart&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${CADDY_FILES_PATH}:/data&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Proxying a service is now a matter of just adding some labels:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;services&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;nextcloud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;nextcloud:latest&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;container_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;nextcloud&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NEXTCLOUD_HOST}&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy.reverse_proxy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"{{upstreams&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;80}}"&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;caddy.request_body.max_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;${NEXTCLOUD_MAX_UPLOAD}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;And that was it!&lt;/p&gt;
&lt;p&gt;&lt;a href="https://caddyserver.com/docs/automatic-https"&gt;Caddy automatically manages the TLS certificates&lt;/a&gt;, so no need for any extra services.
It doesn't get any easier than that.&lt;/p&gt;
&lt;p&gt;I was also able to remove the Nginx config files, instead replacing them with a couple labels in the containers that required some special configuration.&lt;/p&gt;
&lt;p&gt;Before I close off this article I want to highlight how well &lt;em&gt;nginx-proxy&lt;/em&gt; and its &lt;em&gt;acme-companion&lt;/em&gt; had been working for &lt;strong&gt;years&lt;/strong&gt; with minimal maintenance.
Their stability and backwards compatibility had been rock solid, kudos and a big thanks to their creator!&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="caddy"/><category term="docker"/><category term="homelab"/><category term="nginx"/></entry><entry><title>Automating Python code quality</title><link href="https://blog.fidelramos.net/software/python-code-quality" rel="alternate"/><published>2023-06-23T00:00:00+00:00</published><updated>2026-03-30T08:56:23.458200+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2023-06-23:/software/python-code-quality</id><summary type="html">&lt;p&gt;In this article I explain what I mean by code quality and how it benefits developers.&lt;/p&gt;
&lt;p&gt;In the first half I discuss general concepts and workflows that apply to most software projects.
Even if you are not writing Python code you might learn something from it.&lt;/p&gt;
&lt;p&gt;In the second half …&lt;/p&gt;</summary><content type="html">&lt;p&gt;In this article I explain what I mean by code quality and how it benefits developers.&lt;/p&gt;
&lt;p&gt;In the first half I discuss general concepts and workflows that apply to most software projects.
Even if you are not writing Python code you might learn something from it.&lt;/p&gt;
&lt;p&gt;In the second half I offer a step-by-step guide to setting up tooling to improve the code quality of Python projects.
I focus on tools I have used and favor after 15 years of professional experience with Python.
I list some available alternatives to each of my proposals.&lt;/p&gt;
&lt;div class="toc"&gt;&lt;span class="toctitle"&gt;Table of Contents&lt;/span&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#code-quality"&gt;Code quality&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#why-care-about-code-quality"&gt;Why care about code quality?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#why-automate-code-quality"&gt;Why automate code quality?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#categories-under-consideration"&gt;Categories under consideration&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#linting"&gt;Linting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#formatting"&gt;Formatting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#type-hinting"&gt;Type-hinting&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#what-about-tests"&gt;What about tests?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#what-about-code-coverage"&gt;What about code coverage?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#python-code-quality-tools"&gt;Python code quality tools&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#flake8"&gt;flake8&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#black"&gt;Black&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#isort"&gt;isort&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#mypy"&gt;mypy&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#other-tools-to-consider"&gt;Other tools to consider&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#integrations"&gt;Integrations&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#ide"&gt;IDE&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#continuous-integration-ci"&gt;Continuous Integration (CI)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#pre-commit"&gt;pre-commit&lt;/a&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#mypy-and-pre-commit"&gt;Mypy and pre-commit&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;a href="#conclusion"&gt;Conclusion&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h1 id="code-quality"&gt;Code quality&lt;/h1&gt;
&lt;p&gt;All coders develop a (subjective) sense of nice or ugly code, and it gets stronger the more experienced we get with a particular technology.&lt;/p&gt;
&lt;p&gt;Sometimes it's hard to put into words, but it's usually a combination of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Formatting rules.&lt;/li&gt;
&lt;li&gt;Hard errors, e.g. referencing an undeclared variable or dereferencing a null pointer.&lt;/li&gt;
&lt;li&gt;Soft errors or warnings, e.g. having an unused import or variable.&lt;/li&gt;
&lt;li&gt;Language idioms.&lt;/li&gt;
&lt;li&gt;Comments.&lt;/li&gt;
&lt;li&gt;Documentation.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;All these have one aspect in common: they are not part of the strict definition of the programming language, so developers are free to do things in different ways.
Whenever there is room to do things differently, people will do it in any way possible, and fight for their reasons to do it their way.&lt;/p&gt;
&lt;p&gt;I define &lt;em&gt;code quality&lt;/em&gt; as the set of metrics that apply directly over the source code artifacts (i.e. the source files).
These metrics are a subset of &lt;a href="https://en.wikipedia.org/wiki/Non-functional_requirement"&gt;non-functional requirements&lt;/a&gt;.
Different projects will care more or less about each metric, and might ignore some of them.&lt;/p&gt;
&lt;h2 id="why-care-about-code-quality"&gt;Why care about code quality?&lt;/h2&gt;
&lt;p&gt;Even if end users of the software might not directly notice, higher code quality has clear benefits:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Reduced count of bugs per line of code (LoC).&lt;/li&gt;
&lt;li&gt;Consistent formatting leads to more &lt;a href="https://en.wikipedia.org/wiki/Computer_programming#Readability_of_source_code"&gt;readable code&lt;/a&gt;, which improves &lt;a href="https://en.wikipedia.org/wiki/Maintainability"&gt;maintainability&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Better code introspection by having richer type information. Explicit function parameter types, jumping to definitions, browsing documentation...&lt;/li&gt;
&lt;li&gt;Happier developers. This is more important than it sounds, because current developers will be more productive and likelier to stay longer with the project, and it also makes it more attractive to new recruits. "Quality of your projects" ranks #2 in developer happiness, and "quality of development tools" is #4, according to &lt;a href="https://web.archive.org/web/20230429193820/https://www.zenhub.com/reports/software-developer-happiness"&gt;Zenhub's 2022 report on developer happiness&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;But nothing is free, and the downside is obvious: time and effort have to be invested to keep code quality high.&lt;/p&gt;
&lt;p&gt;Fortunately we have a way out: automation.&lt;/p&gt;
&lt;h2 id="why-automate-code-quality"&gt;Why automate code quality?&lt;/h2&gt;
&lt;p&gt;Manually keeping code quality high has a constant level of effort.
By automating these processes, most of the effort is paid upfront when setting up the tooling.&lt;/p&gt;
&lt;p&gt;These tools have some maintenance cost, like upgrading versions or onboarding new developers, but for big or long-running software projects the benefits should outweigh the costs.&lt;/p&gt;
&lt;p&gt;The benefits stack:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Detect errors earlier in the software life cycle (ideally as the developer is writing the code).&lt;/li&gt;
&lt;li&gt;Less or no time spent formatting code. Even better: less mental overhead as you stop paying attention to formatting and focus more on what the code is doing.&lt;/li&gt;
&lt;li&gt;No time spent discussing with other developers about how &lt;em&gt;exactly&lt;/em&gt; to format the code. And besides time, also avoid negative feelings that can arise in those discussions.&lt;/li&gt;
&lt;li&gt;Happier developers. Yes, I mentioned this before, but if a high-quality codebase makes programmers happy, an automated one even more so.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="categories-under-consideration"&gt;Categories under consideration&lt;/h2&gt;
&lt;p&gt;These are the specific code quality categories that will automated for Python in the second half of the article.&lt;/p&gt;
&lt;h3 id="linting"&gt;Linting&lt;/h3&gt;
&lt;p&gt;Linting is the detection of errors and warnings of all kind by static code analysis, i.e. parsing code without actually executing it.&lt;/p&gt;
&lt;p&gt;Linting hints cover a wide range of issues, from "referenced an undeclared variable" to "unused import" or "potentially insecure string interpolation".
Some linting warnings can quickly get pedantic and unhelpful, but the hard errors are definitely good to know about as early as possible, before your code runs for real.&lt;/p&gt;
&lt;p&gt;In a compiled language (e.g. C, C++, Rust, Go...) the compiler catches the errors so the program will not even build until they are fixed.
In an interpreted language such as Python or Javascript many of these errors happen at runtime.
That is where a linter shines by detecting them before the program runs.&lt;/p&gt;
&lt;h3 id="formatting"&gt;Formatting&lt;/h3&gt;
&lt;p&gt;Code format is every stylistic aspect that can be changed without making the code fail.
Different developers have different preferences on how to style code, so much so that there are discussions that span decades:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Tabs or spaces?&lt;/li&gt;
&lt;li&gt;How many spaces per indent level? 2? 4? 8?&lt;/li&gt;
&lt;li&gt;Single quotes (&lt;code&gt;'&lt;/code&gt;) or double quotes (&lt;code&gt;"&lt;/code&gt;)?&lt;/li&gt;
&lt;li&gt;Opening brace at the function declaration/flow-control statement or at the following line?&lt;/li&gt;
&lt;li&gt;How to format function definitions? Parameters on same or separate lines? One parameter per line or up to the maximum line width?&lt;/li&gt;
&lt;li&gt;How many blank lines between module declarations?&lt;/li&gt;
&lt;li&gt;How to sort imports?&lt;/li&gt;
&lt;li&gt;Etcetera.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A codebase with consistent formatting makes it faster to read and understand the code, which is why many developers care about formatting.
The problem is that opinions vary, and different developers want to format things in different ways.
These discussions are not just a time sink but also they can mine morale by pitting developers against each other, sometimes splitting them into winners and losers.&lt;/p&gt;
&lt;p&gt;When formatting is automated those discussions go away.
At most there would be initial discussion on the exact configuration to use for the formatting tool, but once set it will rarely change, as that would cause a reformatting of the whole codebase.&lt;/p&gt;
&lt;p&gt;Another common counter-argument by some developers is that they are too attached to their way of formatting and no tool matches it exactly.
If you think like that I encourage you to give automated formatting a serious try.
For me the pure joy of auto-formatting gets over any personal preferences I might have.
After a while I wondered why I even cared in the first place.&lt;/p&gt;
&lt;h3 id="type-hinting"&gt;Type-hinting&lt;/h3&gt;
&lt;p&gt;This is specific to Python and Javascript/Typescript (as the most popular languages with optional type-hinting, I'm not saying there are no others!).&lt;/p&gt;
&lt;p&gt;Both Python and Javascript are &lt;em&gt;dynamically typed&lt;/em&gt; languages, which means that the type of a given variable can change at runtime, as opposed to &lt;em&gt;statically typed&lt;/em&gt; languages, where all types are set and checked at compilation time and cannot change at runtime.&lt;/p&gt;
&lt;p&gt;Python is &lt;em&gt;strongly typed&lt;/em&gt;, so every variable has a specific type at any point in time during the execution of the program.
On the other hand Javascript is &lt;em&gt;weakly typed&lt;/em&gt;, as variables can change type depending on the context sometimes (e.g. &lt;code&gt;'1' + 1&lt;/code&gt; will cause a cast of &lt;code&gt;1&lt;/code&gt; into a string for a surprising final result of &lt;code&gt;'11'&lt;/code&gt;). Typescript is &lt;em&gt;strongly typed&lt;/em&gt; which is a good reason to prefer it over Javascript.&lt;/p&gt;
&lt;p&gt;As with almost everything in programming there are pros and cons to each approach, and discussing them goes beyond the goal of this article.&lt;/p&gt;
&lt;p&gt;In the case of Python it wasn't until &lt;a href="https://peps.python.org/pep-0484/"&gt;PEP 484&lt;/a&gt; and Python 3.5 that it became possible to annotate the code with the expected type of variables, arguments and function returns.
Many years later now there are tools to not only check the code according to the type hints, but also to do other cool stuff like runtime data validation, like &lt;a href="https://pydantic-docs.helpmanual.io/"&gt;Pydantic&lt;/a&gt; does.&lt;/p&gt;
&lt;p&gt;Type-hinting would be worth a full blog post about its benefits, but for this article I will focus on type checks, which can detect a variety of bugs that linting alone cannot.&lt;/p&gt;
&lt;h3 id="what-about-tests"&gt;What about tests?&lt;/h3&gt;
&lt;p&gt;First of all: &lt;em&gt;of course you should have tests&lt;/em&gt;!
However they are out of scope for this blog post as they can hardly be fully automated, in the sense of setting them up once and they will cover future code without any additional work.
Also tests are usually application-specific, so they are not directly shareable between projects like the proposed tools in the article are.&lt;/p&gt;
&lt;h3 id="what-about-code-coverage"&gt;What about code coverage?&lt;/h3&gt;
&lt;p&gt;Code coverage, i.e. counting how much code is covered by tests and how much isn't, is also a common code quality metric.
I am not including it in this article for two reasons:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;It wraps the unit test runner, which is outside of the scope of this article.&lt;/li&gt;
&lt;li&gt;Code coverage arguably contains a perverse incentive for developers: to avoid adding code that would be hard to test.
For example detecting error cases implies extra code and specific test cases, and therefore extra effort to maintain the code coverage, so a developer might think of not handling some errors and let them bubble up the stack trace.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Code coverage can be a good quality metric for some projects and teams.
Pay special attention so it doesn't actually become a detriment to code quality.&lt;/p&gt;
&lt;p&gt;If you are interested, the most common way of measuring code coverage in Python is through the &lt;a href="https://pypi.org/project/coverage/"&gt;coverage package&lt;/a&gt;.&lt;/p&gt;
&lt;h1 id="python-code-quality-tools"&gt;Python code quality tools&lt;/h1&gt;
&lt;h2 id="flake8"&gt;flake8&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://flake8.pycqa.org/en/latest/"&gt;flake8&lt;/a&gt; is a linter, it will check for a wide variety of issues ranging from errors (e.g. mention to an undeclared variable) to warnings (e.g. unused import) to styling (e.g. long lines or excessive whitespace).&lt;/p&gt;
&lt;p&gt;This is a good starting configuration, place it in a &lt;em&gt;.flake8&lt;/em&gt; file in the project root:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[flake8]&lt;/span&gt;
&lt;span class="na"&gt;max-line-length&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;90&lt;/span&gt;
&lt;span class="na"&gt;extend-ignore&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;E501, E203, W503&lt;/span&gt;
&lt;span class="na"&gt;per-file-ignores&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;settings*.py&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="s"&gt;E402,F403,F405&lt;/span&gt;
&lt;span class="na"&gt;exclude&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;.git,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;__pycache__,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;.tox,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;.eggs,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;*.egg,&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="na"&gt;.venv&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;max-line-length&lt;/code&gt; should match whatever you configure in &lt;em&gt;Black&lt;/em&gt; (see next section).
I like 90, but feel free to use less or more.
&lt;em&gt;Black&lt;/em&gt;'s default is 88.
I wouldn't recommend going over 100, as long lines can be harder to read in diff tools such as Github's split view:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/automating_python_code_quality/github_split_diff.png" title="Click to see full size"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/automating_python_code_quality/github_split_diff.png" title="Click to see full size"/&gt;&lt;figcaption&gt;Example of split diff with long lines&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;per-file-ignores&lt;/code&gt; is useful to ignore some errors or warnings in some files.
In this example I'm ignoring the warning about using &lt;code&gt;import *&lt;/code&gt; in Django settings files, as that is a common pattern.
Feel free to tailor it to your specific case.&lt;/p&gt;
&lt;p&gt;Some alternatives to &lt;em&gt;flake8&lt;/em&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://pylint.pycqa.org/en/latest/"&gt;pylint&lt;/a&gt;: a configurable linter with plugin support.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/PyCQA/pyflakes"&gt;pyflakes&lt;/a&gt;: a simpler linter that ignores styling.
Faster as it parses source files instead of importing them.
No per-project configurability.
No plugins.
It can be a good choice for projects using automated formatting tools like &lt;em&gt;Black&lt;/em&gt; and &lt;em&gt;isort&lt;/em&gt; and that don't use plugins.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="black"&gt;Black&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://black.readthedocs.io/en/stable/"&gt;Black&lt;/a&gt; is a Python code formatter.
It will take any Python file (as long as it doesn't have syntax errors) and change it to conform to &lt;a href="https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html"&gt;Black's code style&lt;/a&gt;: split long lines; use double quotes in strings; remove spurious whitespace and empty lines; and a lot more.
In short, Black will make files &lt;a href="https://peps.python.org/pep-0008/"&gt;PEP 8&lt;/a&gt; compatible.&lt;/p&gt;
&lt;p&gt;Many programmers are very opinionated about style, and don't want a tool to enforce a style they don't like.
However automating code formatting not only saves a ton of time but it also frees us from &lt;strong&gt;not having to discuss styling with other developers&lt;/strong&gt;.
If you are like me, once you let go of whatever specific style you prefer you will feel the bliss and never go back.
Saving a Python file in the editor and seeing it autoformat instantaneously still feels magical.&lt;/p&gt;
&lt;p&gt;So to use &lt;em&gt;Black&lt;/em&gt;, just install it with &lt;em&gt;pip&lt;/em&gt; then add this to &lt;em&gt;pyproject.toml&lt;/em&gt; file in the project root:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[tool.black]&lt;/span&gt;
&lt;span class="n"&gt;line-length&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;
&lt;span class="n"&gt;target-version&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'py310'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;extend-exclude&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;'''&lt;/span&gt;
&lt;span class="s1"&gt;(&lt;/span&gt;
&lt;span class="s1"&gt;  migrations   # Django DB migrations&lt;/span&gt;
&lt;span class="s1"&gt;)&lt;/span&gt;
&lt;span class="s1"&gt;'''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Configuration is straightforward and uses good defaults.
You could run &lt;em&gt;Black&lt;/em&gt; without any configuration at all and it would work great, just be aware that &lt;code&gt;line-length&lt;/code&gt; defaults to 88.
I like explicit configuration so I recommend adding a configuration file even if minimal.&lt;/p&gt;
&lt;p&gt;Notice the use of &lt;code&gt;extend-exclude&lt;/code&gt; instead of &lt;code&gt;exclude&lt;/code&gt;.
&lt;em&gt;Black&lt;/em&gt;'s &lt;code&gt;exclude&lt;/code&gt; setting has a &lt;a href="https://github.com/psf/black/blob/main/src/black/const.py#L2"&gt;sane default&lt;/a&gt; that will exclude most files that you would usually want (&lt;code&gt;.git&lt;/code&gt; and &lt;code&gt;.hg&lt;/code&gt;, &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;dist&lt;/code&gt;, etc.).
So unless there are files in &lt;code&gt;exclude&lt;/code&gt; that you &lt;em&gt;actually want to include&lt;/em&gt; then better stick to &lt;code&gt;extend-exclude&lt;/code&gt; to append to the default &lt;code&gt;exclude&lt;/code&gt; values.&lt;/p&gt;
&lt;p&gt;When introducing &lt;em&gt;Black&lt;/em&gt; into an existing codebase you can &lt;a href="https://black.readthedocs.io/en/stable/guides/introducing_black_to_your_project.html#avoiding-ruining-git-blame"&gt;use this trick to avoid ruining git blame&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Black&lt;/em&gt; documentation also includes a guide on &lt;a href="https://black.readthedocs.io/en/stable/guides/using_black_with_other_tools.html"&gt;configuring other tools to be compatible with black&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;When running &lt;em&gt;Black&lt;/em&gt; in CI without &lt;em&gt;pre-commit&lt;/em&gt; use &lt;code&gt;--check&lt;/code&gt; option so files are not actually reformatted, it will just give a pass/fail result with the faulty files.&lt;/p&gt;
&lt;p&gt;Some alternatives to &lt;em&gt;Black&lt;/em&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/google/yapf"&gt;yapf&lt;/a&gt;: created by Google.
Still considered beta as of the time of this writing, while &lt;em&gt;Black&lt;/em&gt; released its &lt;a href="https://github.com/psf/black/releases/tag/22.1.0"&gt;first stable version&lt;/a&gt; on Jan 29, 2022.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/hhatto/autopep8"&gt;autopep8&lt;/a&gt;: uses &lt;a href="https://github.com/PyCQA/pycodestyle"&gt;pycodestyle&lt;/a&gt;. It can correct deprecated code, so it can be useful for migrating codebases from Python 2 to 3.x.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="isort"&gt;isort&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://pycqa.github.io/isort/index.html"&gt;&lt;em&gt;isort&lt;/em&gt;&lt;/a&gt; will automatically sort all imports in your Python files following a given configuration, or as they say in their home page: &lt;em&gt;isort your imports, so you don't have to&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The imports will be split into groups and sorted alphabetically.
The groups are:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Python standard library.&lt;/li&gt;
&lt;li&gt;Third-party dependencies.&lt;/li&gt;
&lt;li&gt;Current project dependencies.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Configuration goes into the same &lt;em&gt;pyproject.toml&lt;/em&gt; file that &lt;em&gt;Black&lt;/em&gt; uses.
This is an starting point:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[tool.isort]&lt;/span&gt;
&lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"black"&lt;/span&gt;
&lt;span class="n"&gt;line_length&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;
&lt;span class="n"&gt;multi_line_output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="n"&gt;skip_gitignore&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;skip_glob&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"**/migrations/*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"**/settings/*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;src_paths&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;your_code_dir&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;The key is to use &lt;code&gt;profile = "black"&lt;/code&gt; to make it compatible with how &lt;em&gt;Black&lt;/em&gt; expects imports to be sorted.
This gets us the best of both tools: &lt;em&gt;Black&lt;/em&gt; will format the files and &lt;em&gt;isort&lt;/em&gt; will sort the imports, without interfering with each other.
&lt;em&gt;isort&lt;/em&gt; has &lt;a href="https://pycqa.github.io/isort/docs/configuration/profiles.html"&gt;many other profiles available&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Of course make &lt;code&gt;line_length&lt;/code&gt; match the same setting from &lt;em&gt;Black&lt;/em&gt; and &lt;em&gt;flake8&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;In &lt;code&gt;skip_globs&lt;/code&gt; you can put files that shouldn't be automatically processed by &lt;em&gt;isort&lt;/em&gt;.
In the example I'm excluding Django DB migrations, as they are automatically generated, and setting files, that in some projects have bad-style &lt;em&gt;import&lt;/em&gt;s, like star imports.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;isort&lt;/em&gt; has a &lt;code&gt;--check&lt;/code&gt; flag same as &lt;em&gt;Black&lt;/em&gt;, use it in CI if running without &lt;em&gt;pre-commit&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="mypy"&gt;mypy&lt;/h2&gt;
&lt;p&gt;Type hints are a relatively new addition to the Python language, starting with &lt;a href="https://peps.python.org/pep-0484/"&gt;PEP 484&lt;/a&gt; in Python 3.5.
Since Python has always been a dynamically-typed language, any typing extension is something optional that developers can choose whether to use or not.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://mypy-lang.org/index.html"&gt;Mypy&lt;/a&gt; is a static type checker for Python.
It will parse the type hints and determine whether the program is correct or whether there are inconsistencies.
For example if a variable can be a &lt;code&gt;string&lt;/code&gt; or &lt;code&gt;null&lt;/code&gt; and you try to invoke a string method on it, &lt;em&gt;mypy&lt;/em&gt; will throw an error.
At that point you can check the code and control for the &lt;code&gt;null&lt;/code&gt; case appropriately, thus avoiding a potential runtime bug.&lt;/p&gt;
&lt;p&gt;Type checking being optional in Python, it actually requires quite a bit of effort from the developer to add the type hints.
It will reduce the number of bugs and make the code easier to read and understand, but it's not clear whether this effort is worth it from a productivity standpoint.
I think it's worth it for long-running projects, especially those starting from scratch.
There are also type-aware libraries like &lt;a href="https://docs.pydantic.dev/latest/"&gt;Pydantic&lt;/a&gt; that make type hints useful for other use cases, and combined with type checking can make it worthwhile for more projects.&lt;/p&gt;
&lt;p&gt;So on to setting up &lt;em&gt;mypy&lt;/em&gt;, it has &lt;a href="https://mypy.readthedocs.io/en/stable/config_file.html"&gt;extensive configuration&lt;/a&gt;.
It supports either a &lt;em&gt;mypy.ini&lt;/em&gt; file or it can use &lt;em&gt;pyproject.toml&lt;/em&gt;.
I recommend using &lt;em&gt;pyproject.toml&lt;/em&gt; as it's becoming the standard file for all Python tooling, I consider other configuration files as deprecated.&lt;/p&gt;
&lt;p&gt;This is an example &lt;em&gt;mypy&lt;/em&gt; configuration in &lt;em&gt;pyproject.toml&lt;/em&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="k"&gt;[tool.mypy]&lt;/span&gt;
&lt;span class="n"&gt;mypy_path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./src"&lt;/span&gt;
&lt;span class="n"&gt;follow_imports&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"silent"&lt;/span&gt;
&lt;span class="n"&gt;strict_optional&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;warn_redundant_casts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;warn_unused_ignores&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;disallow_any_generics&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;check_untyped_defs&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;no_implicit_reexport&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="k"&gt;[mypy-pyproj.&lt;/span&gt;&lt;span class="err"&gt;*&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="c1"&gt;# use for dependencies that don't have typing stubs&lt;/span&gt;
&lt;span class="n"&gt;ignore_missing_imports&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;True&lt;/span&gt;

&lt;span class="k"&gt;[tool.pytest.ini_options]&lt;/span&gt;
&lt;span class="n"&gt;addopts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="s2"&gt;"--import-mode=importlib"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;pythonpath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"src"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;For external dependencies that don't have type hints you should check and install the type stubs, they usually have the &lt;a href="https://pypi.org/search/?q=types-"&gt;types- prefix&lt;/a&gt;.
They will provide type hints that &lt;em&gt;mypy&lt;/em&gt; can use.
For packages with no type hints and no stubs available you can use the &lt;code&gt;ignore_missing_imports&lt;/code&gt; option as in the example above to silence &lt;em&gt;mypy&lt;/em&gt; errors.&lt;/p&gt;
&lt;p&gt;For larger codebases you want to check out the &lt;a href="https://mypy.readthedocs.io/en/stable/mypy_daemon.html"&gt;mypy daemon&lt;/a&gt; to speed up checking.&lt;/p&gt;
&lt;p&gt;Do check &lt;a href="https://mypy.readthedocs.io/en/stable/existing_code.html"&gt;mypy documentation on adding to an existing codebase&lt;/a&gt; if that is your case.&lt;/p&gt;
&lt;p&gt;Alternatives to &lt;em&gt;mypy&lt;/em&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/microsoft/pyright"&gt;pyright&lt;/a&gt;: A Microsoft project.
Combines a type checker with a language server.
&lt;em&gt;pyright&lt;/em&gt; is open-source but there is also &lt;a href="https://github.com/microsoft/pylance-release"&gt;pylance&lt;/a&gt; which is closed-source but leverages &lt;em&gt;pyright&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pyre-check.org/"&gt;pyre&lt;/a&gt;: a type checker with a focus on performance.
Claims to be "performant on large codebases with millions of lines of Python".
Created by Facebook.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="other-tools-to-consider"&gt;Other tools to consider&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://github.com/PyCQA/bandit"&gt;bandit&lt;/a&gt; checks for potential security bugs.
It &lt;a href="https://bandit.readthedocs.io/en/latest/start.html#version-control-integration"&gt;integrates with pre-commit&lt;/a&gt;.
There is a &lt;a href="https://github.com/tylerwince/flake8-bandit"&gt;flake8-bandit plugin&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/PyCQA/flake8-bugbear"&gt;flake8-bugbear&lt;/a&gt; is a flake8 plugin that adds additional checks for some Python pitfalls, i.e. code that can have unexpected results.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/pycqa/pydocstyle"&gt;pydocstyle&lt;/a&gt; automatically formats docstrings.
If you are using flake8 check out the &lt;a href="https://github.com/PyCQA/flake8-docstrings"&gt;flake8-docstrings plugin&lt;/a&gt;.&lt;/p&gt;
&lt;h1 id="integrations"&gt;Integrations&lt;/h1&gt;
&lt;p&gt;The tools I have presented can be invoked in a CLI, but that is not how you want to use them most of the time.
Remember, the goal is automation.&lt;/p&gt;
&lt;p&gt;This section is split into 3 parts:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;IDE (Integrated Development Environment), i.e. your code editor.&lt;/li&gt;
&lt;li&gt;CI (Continuous Integration).&lt;/li&gt;
&lt;li&gt;pre-commit hooks.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="ide"&gt;IDE&lt;/h2&gt;
&lt;p&gt;If you are reading this article and are already a programmer, chances are you are already using an IDE of some kind.
Whichever IDE it is, you should learn how to set up all these tools in your IDE.
If your code editor doesn't allow for running all the necessary tools then it might be time to look for another one.
It's a bit out of scope for the article, but talking IDEs is always fun, so I will leave just some thoughts and recommendations for whoever wants to listen.&lt;/p&gt;
&lt;p&gt;Before jumping onto specific IDE recommendations, I want to mention the &lt;a href="https://microsoft.github.io/language-server-protocol/"&gt;Language Server Protocol&lt;/a&gt;, or LSP.
Modern IDEs don't implement features like auto-complete, jump to definition, find references, refactor operations, etc.
They communicate with a language server that offers those operations.
For example for Python there is &lt;a href="https://github.com/python-lsp/python-lsp-server/"&gt;python-lsp-server&lt;/a&gt;, which has a variety of plugins to integrate with different tools.
This way your editor doesn't need to worry about using &lt;em&gt;Black&lt;/em&gt; for formatting or &lt;em&gt;flake8&lt;/em&gt; for linting, just to communicate with a LSP server that supports them.&lt;/p&gt;
&lt;p&gt;About IDE recommendations, I personally use &lt;a href="https://www.gnu.org/software/emacs/"&gt;Emacs&lt;/a&gt; and don't think I will ever change.
The learning curve is like no other, but for that same reason it can take you higher than any other editor (flamewar!).
To connect to a LSP server you want to use either &lt;a href="https://emacs-lsp.github.io/lsp-mode/"&gt;lsp-mode&lt;/a&gt; or &lt;a href="https://joaotavora.github.io/eglot/"&gt;eglot&lt;/a&gt; (which is part of Emacs itself as of Emacs 29).
If you are curious about my Emacs configuration it's &lt;a href="https://github.com/haplo/dotemacs"&gt;published here&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;A lot of people like Vim.
It is a great editor, fast and available in virtually every UNIX-like operating system in the form of the venerable &lt;em&gt;vi&lt;/em&gt;.
I used it for years until I decided to give Emacs a try after hearing so much about it.
I have heard that &lt;a href="https://neovim.io/"&gt;Neovim&lt;/a&gt; is amazing, definitely check it out if you are into Vim.&lt;/p&gt;
&lt;p&gt;Nowadays &lt;a href="https://code.visualstudio.com/"&gt;Visual Studio Code&lt;/a&gt;, or VSCode, is the popular new kid on the block.
And for good reason: the developer experience is great, easy to set up and add plugins to do all kinds of stuff.
I dislike it because it's closed-source software, so I would bet it's a matter of time until Microsoft pulls the rug under its users in some nasty way, like it's happened &lt;a href="https://en.wikipedia.org/wiki/BitKeeper"&gt;time&lt;/a&gt; &lt;a href="https://en.wikipedia.org/wiki/Flickr#Deletion_of_files_of_non-paying_users"&gt;and&lt;/a&gt; &lt;a href="https://www.office.com/"&gt;time&lt;/a&gt; &lt;a href="https://web.archive.org/web/20220703064153/https://discussion.evernote.com/forums/topic/97299-two-device-limit-is-a-bad-idea/"&gt;again&lt;/a&gt; with closed-source software.
At least there is &lt;a href="https://github.com/VSCodium/vscodium"&gt;VSCodium&lt;/a&gt; which is a free-software version with the proprietary bits removed, like Chromium is to Chrome, but who knows how long it will last.&lt;/p&gt;
&lt;p&gt;If you are a young developer, or someone willing to put in the effort, I recommend choosing between Emacs and Vim/Neovim and committing to it for a few months.
You won't regret it, they will last you a lifetime.&lt;/p&gt;
&lt;h2 id="continuous-integration-ci"&gt;Continuous Integration (CI)&lt;/h2&gt;
&lt;p&gt;It's important to notice that all these tools and checks on a codebase can be undone by an uncollaborative or uncaring developer.
For this reason it's a good idea (some would say a requirement) to enforce the checks, keeping developers from merging any breaking changes.
Usually this is done by a system that runs the necessary checks on the code before it is merged to the main branch.
That system is called a Continuous Integration system, or CI.&lt;/p&gt;
&lt;p&gt;The workflow for getting changes into the project then becomes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Developer commits changes to a branch and creates a Pull Request (PR) in the code hosting service (e.g. Github or Gitlab).&lt;/li&gt;
&lt;li&gt;Code hosting notifies the CI tool, which starts a new job to check the branch at its current commit.&lt;/li&gt;
&lt;li&gt;Developer cannot merge until the job finishes successfully (usually there are special permissions for some users to override this and merge anyway).&lt;/li&gt;
&lt;li&gt;On a successful job, developer can merge the changes. If new changes are added another CI job will be triggered and flow will restart.&lt;/li&gt;
&lt;li&gt;On errors the developer can look at the job log to figure out what is wrong, fix it and push the new changes. This will trigger a new CI job and restart the flow.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;There are dozens of CI platforms out there, but I will list a few that I have worked with:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://jenkins.io/"&gt;Jenkins&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://circleci.com/"&gt;CircleCI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://travis-ci.org/"&gt;TravisCI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/features/actions"&gt;Github Actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://about.gitlab.com/features/continuous-integration/"&gt;Gitlab CI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/codebuild/"&gt;AWS CodeBuild&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="pre-commit"&gt;pre-commit&lt;/h2&gt;
&lt;p&gt;When introducing CI-enforced checks it becomes painful for developers to push some changes and then seeing an error several minutes later.
The feedback loop between errors and their fix should be as short as possible for maximum developer productivity (and happiness!).
For this reason it's a good idea to not just allow the developers to run the checks locally, but to make it easy and fast to do so.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://pre-commit.com/"&gt;pre-commit&lt;/a&gt; is &lt;quote&gt;a framework for managing and maintaining multi-language pre-commit hooks&lt;/quote&gt;.
&lt;a href="https://git-scm.com/docs/githooks"&gt;git hooks&lt;/a&gt; are just programs that can be set up in &lt;em&gt;git&lt;/em&gt; repositories to be executed when &lt;em&gt;git&lt;/em&gt; triggers certain actions.
As &lt;em&gt;pre-commit&lt;/em&gt;'s name suggests it commonly runs before a commit is created, but &lt;a href="https://pre-commit.com/#pre-commit-during-push"&gt;it can be set up to run before git push&lt;/a&gt; or at some other point if necessary.&lt;/p&gt;
&lt;p&gt;Benefits of &lt;em&gt;pre-commit&lt;/em&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Same checks run by developers and CI, reducing the chances of the developer encountering CI errors later in the development pipeline.
This is the more valuable the slower the CI checks are, as it reduces the feedback loop for developers.&lt;/li&gt;
&lt;li&gt;Quick and easy to add third-party hooks and keep them updated (&lt;code&gt;pre-commit autoupdate&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Opt-in for developers.
They can either not use &lt;em&gt;pre-commit&lt;/em&gt; at all (by just not installing the hooks) or skip the checks for specific commits (by using &lt;code&gt;git commit -n&lt;/code&gt;).
But then don't complain about CI failing! ;-)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Cons of &lt;em&gt;pre-commit&lt;/em&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Yet another dependency/tool to manage.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;pre-commit&lt;/em&gt; installs hooks in a separate environment, which can be wasteful if you are already installing some tools in the project's virtualenv.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;This is an example &lt;em&gt;pre-commit&lt;/em&gt; configuration that integrates my recommended tools, just put it in &lt;em&gt;.pre-commit-config.yaml&lt;/em&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;&lt;span class="nt"&gt;repos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://github.com/pre-commit/pre-commit-hooks&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;v4.4.0&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;check-json&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;check-toml&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;check-yaml&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;check-merge-conflict&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;debug-statements&lt;/span&gt;
&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://github.com/PyCQA/isort&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;5.12.0&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;isort&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;--filter-files&lt;/span&gt;&lt;span class="p p-Indicator"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;src/&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;
&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://github.com/psf/black&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;23.3.0&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;black&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;migrations&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;--check&lt;/span&gt;&lt;span class="p p-Indicator"&gt;,&lt;/span&gt;&lt;span class="nv"&gt;--config=pyproject.toml&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;
&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;https://github.com/PyCQA/flake8&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;6.0.0&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;flake8&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="nt"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;--config=pyproject.toml&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;
&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;local&lt;/span&gt;
&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nt"&gt;hooks&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="p p-Indicator"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;mypy&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;mypy&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;"sh&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;mypy_run.sh"&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;system&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# run if any Python file is changed&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;types&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p p-Indicator"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;python&lt;/span&gt;&lt;span class="p p-Indicator"&gt;]&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# mypy will always check all files&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;pass_filenames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;false&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="c1"&gt;# use require_serial so that script is only called once per commit&lt;/span&gt;
&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="nt"&gt;require_serial&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l l-Scalar l-Scalar-Plain"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;pre-commit autoupdate&lt;/code&gt; finds new versions of the pre-commit hooks you use and automatically updates the &lt;em&gt;.pre-commit-config.yaml&lt;/em&gt; file to use them.&lt;/p&gt;
&lt;h3 id="mypy-and-pre-commit"&gt;Mypy and pre-commit&lt;/h3&gt;
&lt;p&gt;Making &lt;em&gt;mypy&lt;/em&gt; work in &lt;em&gt;pre-commit&lt;/em&gt; can be challenging, because &lt;em&gt;pre-commit&lt;/em&gt; runs on its own environment, so it won't see any of the installed dependencies for the project.
This is the reason why the &lt;a href="https://github.com/pre-commit/mirrors-mypy"&gt;official mypy hook for pre-commit&lt;/a&gt; has limited usefulness in my opinion.&lt;/p&gt;
&lt;p&gt;My usual hack is to define a shell script that activates the project's virtualenv and invokes &lt;em&gt;mypy&lt;/em&gt;, and have &lt;em&gt;pre-commit&lt;/em&gt; run it whenever a Python file changes.
This is a sample &lt;em&gt;mypy_run.sh&lt;/em&gt; file as referenced in the &lt;em&gt;.pre-commit-config.yaml&lt;/em&gt; example from above:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;./venv/bin/mypy&lt;span class="w"&gt; &lt;/span&gt;src/
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;If you know of a better way of running &lt;em&gt;mypy&lt;/em&gt; with &lt;em&gt;pre-commit&lt;/em&gt; I would love to hear it, please send me a message.&lt;/p&gt;
&lt;h1 id="conclusion"&gt;Conclusion&lt;/h1&gt;
&lt;p&gt;I first gave a short generic introduction to code quality and why it's important for software projects.&lt;/p&gt;
&lt;p&gt;Then I presented ready-to-use configurations using my tools of choice for Python programming, and instructions on how to automate the necessary checks to maximize developer productivity and happiness.&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="programming"/><category term="python"/><category term="automation"/><category term="howto"/><category term="free-software"/></entry><entry><title>Privacy-respecting self-hosted Web Analytics</title><link href="https://blog.fidelramos.net/software/privacy-respecting-self-hosted-web-analytics" rel="alternate"/><published>2022-05-27T00:00:00+00:00</published><updated>2023-12-28T14:55:18.288821+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2022-05-27:/software/privacy-respecting-self-hosted-web-analytics</id><summary type="html">&lt;p&gt;It all started with a desire: I wanted some basic analytics for this blog to know how it's
doing, which articles are popular and which are the main referrers. But therein lied the
conundrum: I am a privacy zealot, very much against extensive online tracking (just look
at the &lt;a href="https://blog.fidelramos.net/software/web-browser-addons"&gt;browser …&lt;/a&gt;&lt;/p&gt;</summary><content type="html">&lt;p&gt;It all started with a desire: I wanted some basic analytics for this blog to know how it's
doing, which articles are popular and which are the main referrers. But therein lied the
conundrum: I am a privacy zealot, very much against extensive online tracking (just look
at the &lt;a href="https://blog.fidelramos.net/software/web-browser-addons"&gt;browser extensions I use&lt;/a&gt;), so using a
third-party service that collected all my visitors' data was against the question.&lt;/p&gt;
&lt;p&gt;I knew there were open-source web analytics projects out there, like Piwik which has been
around for a long time, but never had the time or need to look into them seriously. Until
now.&lt;/p&gt;
&lt;p&gt;Please note that this article is by no means an extensive list. There are a lot of other
options out there, just check out &lt;a href="https://github.com/newTendermint/awesome-analytics"&gt;this big
list&lt;/a&gt;. Each software has its own pros
and cons, but I hope this article can help you choose and make the jump towards
self-hosted analytics, or at least to abandon Google Analytics in favor of an
open-source-based SAAS option.&lt;/p&gt;
&lt;div class="toc"&gt;&lt;span class="toctitle"&gt;Table of Contents&lt;/span&gt;&lt;ul&gt;
&lt;li&gt;&lt;a href="#my-requirements"&gt;My requirements&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#matomo-formerly-piwik"&gt;Matomo (formerly Piwik)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#plausible"&gt;Plausible&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#shynet"&gt;Shynet&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#open-web-analytics-owa"&gt;Open Web Analytics (OWA)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="#conclusions-and-my-winner"&gt;Conclusions and my winner&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;h1 id="my-requirements"&gt;My requirements&lt;/h1&gt;
&lt;p&gt;I researched options for my specific use case:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Must be free software (&lt;em&gt;free as in freedom, not as in free beer&lt;/em&gt;) and self-hosted.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Simple deployment, the fewer moving parts the better. SQLite if possible so no DB
  server will be needed. No Redis/Memcached if it can be avoided. Option to deploy without
  Docker would be nice.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Reasonable resource usage. My sites don't usually get a lot of traffic, so I would like
  to reuse the 2 GB VPS that hosts this blog.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Technology that I can easily hack is a plus. I will favor Python projects, but YMMV.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Lightweight and not too intrusive. I basically just want to know which articles are
   seen when, plus referrers, no need for advanced analytics.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Actively maintained.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1 id="matomo-formerly-piwik"&gt;Matomo (formerly Piwik)&lt;/h1&gt;
&lt;p&gt;&lt;a href="https://matomo.org/"&gt;Home page&lt;/a&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;GPLv3. Paid SAAS cloud service with free &lt;a href="https://matomo.org/matomo-on-premise/"&gt;self-hosting option&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://matomo.org/faq/on-premise/installing-matomo/"&gt;Installation docs&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Requires a webserver (Apache, Nginx, etc.)&lt;/li&gt;
&lt;li&gt;Requires MySQL.&lt;/li&gt;
&lt;li&gt;Configuration of Matomo is all done in-app.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://matomo.org/faq/on-premise/matomo-requirements/"&gt;Matomo self-hosted requirements&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&amp;lt;100k page views/month: 1 server for both app and DB. 2 CPU, 2 GB RAM, 50GB SSD disk.&lt;/li&gt;
&lt;li&gt;~1M page views/month: 1 server for both app and DB. 4 CPU, 8 GB RAM, 250GB SSD disk.&lt;/li&gt;
&lt;li&gt;~10M page views/month: 2 servers for app and DB. 8 CPUs, 16 GB RAM, 400GB SSD disk&lt;/li&gt;
&lt;li&gt;~100M page views/month: 3 or more servers for app, DB and load balancer. 16 CPUs,
  32 GB RAM, 1 TB SSD disk.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;PHP for backend. VueJS and TypeScript for frontend. &lt;a href="https://github.com/matomo-org/matomo"&gt;Github
   repository&lt;/a&gt; and &lt;a href="https://developer.matomo.org/guides/contributing-to-piwik-core"&gt;developer
   guide&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Very comprehensive set of &lt;a href="https://matomo.org/feature-overview/"&gt;collected
   analytics&lt;/a&gt;. &lt;a href="https://matomo.org/installing-matomo-for-wordpress/"&gt;Wordpress
   plugin&lt;/a&gt; available.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;About 10 commits per week, multiple &lt;a href="https://matomo.org/team/"&gt;contributors&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1 id="plausible"&gt;Plausible&lt;/h1&gt;
&lt;p&gt;&lt;a href="https://plausible.io/open-source-website-analytics"&gt;Home page&lt;/a&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;AGPLv3. Paid SAAS cloud service with free &lt;a href="https://plausible.io/self-hosted-web-analytics"&gt;self-hosting
   option&lt;/a&gt;, but the latter lags behind in
   features (2 releases per year).&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;a href="https://plausible.io/docs/self-hosting#up-and-running"&gt;Official installation
   documentation&lt;/a&gt;. tldr; needs
   &lt;em&gt;docker-compose&lt;/em&gt;, PostgreSQL and Clickhouse.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;From &lt;a href="https://plausible.io/docs/self-hosting#requirements"&gt;Plausible's requirements documentation&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The server must have a CPU with x86_64 architecture and support for SSE 4.2
instructions. We recommend using a minimum of 4GB of RAM but the requirements will
depend on your site traffic.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Elixir/Phoenix backend. React and Tailwind frontend. &lt;a href="https://github.com/plausible/analytics"&gt;Github
   repository&lt;/a&gt; and &lt;a href="https://github.com/plausible/analytics/blob/master/CONTRIBUTING.md"&gt;developer
   guide&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&amp;lt;1 KiB tracking script. No cookies. I couldn't find a concrete list of tracked metrics,
   but it seems less extensive than Matomo or OWA.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Weekly activity, usually condensed in long-form pull requests.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1 id="shynet"&gt;Shynet&lt;/h1&gt;
&lt;p&gt;&lt;a href="https://github.com/milesmcc/shynet"&gt;Home page&lt;/a&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;Apache License Version 2.0. Self-hosted only.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Easy deployment with Docker or &lt;em&gt;docker-compose&lt;/em&gt;, otherwise undocumented manual
   deployment. Supports any DB that Django supports, including SQLite. Cache server
   optional.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Low resource consumption, basically just a Python process running Django, ~100 MiB of
   RAM when using SQLite.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Python/Django backend. Uses its own &lt;a href="https://github.com/milesmcc/a17t"&gt;TailwindCSS
   plugin&lt;/a&gt; for frontend. Easy to build from source
   (Docker image). &lt;a href="https://github.com/milesmcc/shynet"&gt;Github repository&lt;/a&gt; and &lt;a href="https://github.com/milesmcc/shynet/blob/master/CONTRIBUTING.md"&gt;developer
   guide&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&amp;lt;1 KiB tracking script. No cookies. 1x1 pixel tracking fallback. Well-defined list of
   &lt;a href="https://github.com/milesmcc/shynet#metrics"&gt;captured metrics&lt;/a&gt;, pretty basic.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Not a lot of new feature development, but dependencies are kept up to date and the main
   developer is very responsive to issues and pull requests (see conclusions).&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1 id="open-web-analytics-owa"&gt;Open Web Analytics (OWA)&lt;/h1&gt;
&lt;p&gt;&lt;a href="https://www.openwebanalytics.com/"&gt;Home page&lt;/a&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;GPLv2. Self-hosted only.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;From &lt;a href="https://github.com/Open-Web-Analytics/Open-Web-Analytics/wiki/Installation"&gt;OWA's installation
   documentation&lt;/a&gt;
   and &lt;a href="https://github.com/Open-Web-Analytics/Open-Web-Analytics/wiki/Technical-Requirements"&gt;technical
   requirements&lt;/a&gt;:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Only PHP projects supported.&lt;/li&gt;
&lt;li&gt;In-app install wizard.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Open-Web-Analytics/Open-Web-Analytics/wiki/Technical-Requirements#web-server"&gt;Only Apache 2.x officially
  supported&lt;/a&gt;. Nginx
  works but needs manual configuration.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Open-Web-Analytics/Open-Web-Analytics/wiki/Technical-Requirements#databases="&gt;MySQL &lt;em&gt;with strict mode
  off&lt;/em&gt;&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Unclear as to the exact requirements.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;PHP backend. Webpack for frontend assets. Multiple repositories: &lt;a href="https://github.com/Open-Web-Analytics/Open-Web-Analytics/wiki/Installation"&gt;base
   repo&lt;/a&gt;,
   &lt;a href="https://github.com/Open-Web-Analytics/owa-wordpress-plugin"&gt;Wordpress plugin repo&lt;/a&gt;,
   &lt;a href="https://github.com/Open-Web-Analytics/owa-php-sdk"&gt;SDK for PHP repo&lt;/a&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Very detailed tracking, including heatmaps and "&lt;em&gt;Domstream session recordings&lt;/em&gt;", but
   only for PHP apps. Dedicated &lt;a href="https://github.com/Open-Web-Analytics/Open-Web-Analytics/wiki/Integration-Plugins"&gt;integration
   plugins&lt;/a&gt;
   for WordPress and MediaWiki.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Actively maintained, but apparently by a single developer.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h1 id="conclusions-and-my-winner"&gt;Conclusions and my winner&lt;/h1&gt;
&lt;p&gt;Looking at the highlighted requirements my choice for this humble blog was easy: Shynet
won hands down, it checked every box. For bigger sites Plausible and Matomo both seem to
be good options. I would say Plausible has a better foundational technology (it launched
in 2019 after all) but Matomo has been around for longer (2007), it's more battle-tested
and seems to collect more data. Plausible has &lt;a href="https://plausible.io/vs-matomo"&gt;a comparison page between Plausible and
Matomo&lt;/a&gt;. OWA might be a good option for PHP and Wordpress
sites.&lt;/p&gt;
&lt;p&gt;Shynet's hackability, point 4 in my requirements list, quickly came into the spotlight
because &lt;a href="https://github.com/milesmcc/shynet/issues/208"&gt;SQLite support was
broken&lt;/a&gt;. Fortunately &lt;a href="https://github.com/milesmcc/shynet/pull/210"&gt;I was able to fix
it&lt;/a&gt; and deploy it into the very server where
you have been reading this article. This is why I love free software.&lt;/p&gt;
&lt;p&gt;Another issue with the deployment was that the server didn't have Docker installed and I
wanted to keep it that way, so I used the opportunity to learn about
&lt;a href="https://podman.io/"&gt;Podman&lt;/a&gt;. I instantly fell in love with it and its ability to run
rootless containers, which is a boon to security. Only issue I encountered was having to
change the permissions of the mounted data volume, otherwise Shynet's Django process was
unable to write the SQLite DB as files were owned by &lt;em&gt;root&lt;/em&gt; inside the container. &lt;a href="https://www.tutorialworks.com/podman-rootless-volumes/"&gt;This
article&lt;/a&gt; explains it well, tldr;
need to use &lt;code&gt;podman unshare&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To end this article, here is a screenshot of Shynet's data for this blog (isn't it great
to be able to share knowing that nobody's privacy is at stake?):&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.fidelramos.net/images/privacy_respecting_self_hosted_web_analytics/shynet.png" title="&amp;quot;Click to see full size"&gt;
&lt;figure class="align-center"&gt;&lt;img src="https://blog.fidelramos.net/images/privacy_respecting_self_hosted_web_analytics/shynet.png" title="Click to see full size"&gt;&lt;figcaption&gt;blog.fidelramos.net on my Shynet instance&lt;/figcaption&gt;&lt;/figure&gt;
&lt;/a&gt;&lt;/p&gt;</content><category term="Software"/><category term="privacy"/><category term="web"/><category term="analytics"/><category term="self-hosting"/></entry><entry><title>Web browser add-ons I use</title><link href="https://blog.fidelramos.net/software/web-browser-addons" rel="alternate"/><published>2022-02-23T00:00:00+00:00</published><updated>2026-03-31T00:00:00+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2022-02-23:/software/web-browser-addons</id><summary type="html">&lt;p&gt;This article lists the web browser extensions I use and why.
I will keep it updated from time to time.&lt;/p&gt;
&lt;p&gt;Currently I use &lt;a href="https://brave.com/"&gt;Brave&lt;/a&gt; as my main web browser, and is the one I recommend to everyone.
It offers a great experience by default, with features such as a built-in …&lt;/p&gt;</summary><content type="html">&lt;p&gt;This article lists the web browser extensions I use and why.
I will keep it updated from time to time.&lt;/p&gt;
&lt;p&gt;Currently I use &lt;a href="https://brave.com/"&gt;Brave&lt;/a&gt; as my main web browser, and is the one I recommend to everyone.
It offers a great experience by default, with features such as a built-in ad blocker, built-in &lt;a href="https://en.wikipedia.org/wiki/Tor_(network)"&gt;Tor&lt;/a&gt; integration, vertical and grouped tabs, a decent cryptocurrency wallet, &lt;a href="https://en.wikipedia.org/wiki/InterPlanetary_File_System"&gt;IPFS&lt;/a&gt; support, built-in &lt;a href="https://talk.brave.com/"&gt;video calls&lt;/a&gt;.
The out-of-the-box experience is better than the alternatives in my opinion, especially for regular users who don't know or don't want to tweak their software.&lt;/p&gt;
&lt;p&gt;I previously used &lt;a href="https://www.mozilla.org/en-US/firefox/new/"&gt;Firefox&lt;/a&gt;.
I appreciate that it is the only major browser with its own rendering engine (Gecko) that is not based on the hegemoneous WebKit that is the base of virtually all the other web browsers.
As a web developer I know how dangerous it is to have a monoculture around a single web browser engine, that is what we suffered in the late 90s and early 00s with Internet Explorer.&lt;/p&gt;
&lt;p&gt;I try to be careful with the browser extensions I install, as they have deep access to
browser data.
This means a malicious or compromised add-on can quickly become a nightmare if it manages for
example to steal the credentials for my email account (although 2FA should mitigate that
risk).
For this reason I carefully weigh the usefulness of the add-ons versus that risk.
The ones I use are usually popular, with thousands of users
and often editor-picked.&lt;/p&gt;
&lt;p&gt;Without further delay, these are the add-ons I'm currently using on Brave:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://keepassxc.org/docs/KeePassXC_GettingStarted.html#_setup_browser_integration"&gt;KeepassXC-Browser&lt;/a&gt;:
  I use &lt;a href="https://keepassxc.org/"&gt;KeepassXC&lt;/a&gt; to hold my passwords and secrets, this is its
  official browser extension which enables form autocomplete. It works pretty well
  although not as well as competitors such as Lastpass or 1Password, but I wouldn't trust
  them with my data.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In Firefox I used to use these extensions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://ublockorigin.com/"&gt;uBlock Origin&lt;/a&gt;: the best ad blocker there is. It uses little
  CPU and RAM, usually saves more than it consumes, i.e. web pages load faster without all
  the crap they usually have (banners, tracking scripts, etc.). Especially on mobile it
  can be &lt;em&gt;very&lt;/em&gt; noticeable. I used to use a bunch of other privacy-related extensions
  (NoScript, ClearURLs, PrivacyBadger, HTTPSEverywhere and DecentralEyes), but after
  reading &lt;a href="https://github.com/arkenfox/user.js/wiki/4.1-Extensions"&gt;this article on
  Arkenfox&lt;/a&gt; I learned that they
  are superfluous if uBlock Origin is properly configured.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://addons.mozilla.org/en-US/firefox/addon/multi-account-containers/"&gt;Firefox Multi-Account
  Container&lt;/a&gt;:
  while Firefox has built-in support for containers, this extensions is needed to manage
  them. It's an official add-on from the Firefox team itself, so I fully trust it. What
  are containers? They are a way of categorizing tabs, in order to isolate the information
  that is accessible. For example I set up my bank's website to open in a &lt;em&gt;Banking&lt;/em&gt;
  container, so a potential attacker that makes my browser run code with a security
  exploit in a different tab wouldn't have access to it unless it was opened in the same
  &lt;em&gt;Banking&lt;/em&gt; container. I also use it to isolate logged-in tracking, for example I have a
  &lt;em&gt;Google&lt;/em&gt; container where I'm signed-in to Google, but in other containers I'm
  logged-out. I use a bunch of other containers: &lt;em&gt;Gov&lt;/em&gt; for government websites, &lt;em&gt;Personal&lt;/em&gt;
  for my self-hosted web apps, &lt;em&gt;Shopping&lt;/em&gt; whenever I'm buying something...&lt;/li&gt;
&lt;li&gt;&lt;a href="https://addons.mozilla.org/en-US/firefox/addon/skip-redirect/"&gt;Skip Redirect&lt;/a&gt;: detects
  and skips redirects, to avoid unnecessary tracking. For example if you look closely you
  will notice that when clicking on a Google result the URL points to Google, which then
  redirects you to the target. This way Google knows which results you visit. This
  extension extracts the target and goes to it directly, thus avoiding tracking.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Updates:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;2022-11-19: Replaced Metamask with Tally Ho; added Sidebery.&lt;/li&gt;
&lt;li&gt;2023-01-20: Added Skip Redirect; removed NoScript, ClearURLs, PrivacyBadger, HTTPSEverywhere, DecentralEyes.&lt;/li&gt;
&lt;li&gt;2023-04-20: Updated Tally Ho to Taho, they &lt;a href="https://blog.taho.xyz/rename-announcement/"&gt;rebranded in Feb 21, 2023&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;2026-03-31: I now use Brave as my main web browser.&lt;/li&gt;
&lt;/ul&gt;</content><category term="Software"/><category term="browser"/><category term="firefox"/><category term="web"/><category term="addons"/><category term="privacy"/><category term="security"/><category term="cryptocurrency"/></entry><entry><title>Nextcloud CardDAV and CalDAV discovery with DNS records</title><link href="https://blog.fidelramos.net/software/nextcloud-caldav-carddav-dns" rel="alternate"/><published>2022-02-17T00:00:00+00:00</published><updated>2023-12-28T14:55:18.288821+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2022-02-17:/software/nextcloud-caldav-carddav-dns</id><summary type="html">&lt;p&gt;I use a self-hosted Nextcloud instance to hold a lot of my personal data, and that
includes my contacts and calendars. Recently I had to reinstall
&lt;a href="https://www.davx5.com"&gt;DAVx5&lt;/a&gt; on my phone, and I was surprised that the Nextcloud account
was failing to be added.&lt;/p&gt;
&lt;p&gt;The DAVx5 error logs showed that it …&lt;/p&gt;</summary><content type="html">&lt;p&gt;I use a self-hosted Nextcloud instance to hold a lot of my personal data, and that
includes my contacts and calendars. Recently I had to reinstall
&lt;a href="https://www.davx5.com"&gt;DAVx5&lt;/a&gt; on my phone, and I was surprised that the Nextcloud account
was failing to be added.&lt;/p&gt;
&lt;p&gt;The DAVx5 error logs showed that it was making requests to the &lt;code&gt;/.well-known/caldav&lt;/code&gt; and
&lt;code&gt;/.well-known/carddav&lt;/code&gt; URLs, as described in &lt;a href="https://datatracker.ietf.org/doc/html/rfc6764#section-5"&gt;RFC
6764&lt;/a&gt;. Nextcloud was returning
proper 301 HTTP responses, checked in a web browser, that redirected to the actual WebDAV
endpoint, so it looks like the problem was that DAVx5 wasn't properly following them.&lt;/p&gt;
&lt;p&gt;Browsing the &lt;a href="https://www.davx5.com/manual/accounts_collections.html#how-does-service-discovery-work"&gt;DAVx5 documentation on service
discovery&lt;/a&gt;
they mention that the DAV standard also supports using SRV and TXT DNS records pointing to
the endpoints to be used for CardDAV and CalDAV, and I decide to give it a go as it
sounded easier than further debugging DAVx5 or digging into Nextcloud's and/or the
webserver's configuration.&lt;/p&gt;
&lt;p&gt;I added the following DNS records to &lt;code&gt;fidelramos.net&lt;/code&gt;:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;_caldavs._tcp 10800 IN SRV 0 1 443 cloud.fidelramos.net.
_caldavs._tcp 10800 IN TXT "path=/remote.php/dav/"
_carddavs._tcp 10800 IN SRV 0 1 443 cloud.fidelramos.net.
_carddavs._tcp 10800 IN TXT "path=/remote.php/dav/"
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;The &lt;code&gt;path&lt;/code&gt; I find by following &lt;code&gt;/.well-known/caldav&lt;/code&gt; and &lt;code&gt;/.well-known/carddav&lt;/code&gt; URLs in my
Nextcloud instance.&lt;/p&gt;
&lt;p&gt;Once the DNS records were in place DAVx5 connected successfully, but I had to use
&lt;code&gt;fidelramos.net&lt;/code&gt; as the base URL, instead of &lt;code&gt;cloud.fidelramos.net&lt;/code&gt; as before.&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="nextcloud"/><category term="howto"/><category term="self-hosting"/><category term="dns"/></entry><entry><title>Unlocking a LUKS-encrypted partition on boot with an USB drive</title><link href="https://blog.fidelramos.net/software/unlock-luks-usb-drive" rel="alternate"/><published>2019-10-13T00:00:00+00:00</published><updated>2019-10-20T23:48:33+00:00</updated><author><name>Fidel Ramos</name></author><id>tag:blog.fidelramos.net,2019-10-13:/software/unlock-luks-usb-drive</id><summary type="html">&lt;p&gt;The use case I wanted to solve was this: I have a headless server with
a LUKS software-encrypted hard drive, and I want to be able to reboot
it without having to input the password on a keyboard. The solution I
implemented is to create a LUKS keyfile on a …&lt;/p&gt;</summary><content type="html">&lt;p&gt;The use case I wanted to solve was this: I have a headless server with
a LUKS software-encrypted hard drive, and I want to be able to reboot
it without having to input the password on a keyboard. The solution I
implemented is to create a LUKS keyfile on a USB drive, so if it is
plugged on boot the keyfile will be used instead of the password.&lt;/p&gt;
&lt;p&gt;Before we begin, a few words on the security of this approach:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;Whoever gets access to the USB drive will be able to decrypt the
  target device. Consider the security requirements of the USB drive:
  could be put on a safe box, hidden away or you could always carry it
  with you.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;It's best if the USB drive is only used for this purpose, as any
  other system where the drive is plugged in can potentially read and
  leak the encryption key.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;This is clearly reducing security to gain in usability. Sometimes
  that is the balance that we are looking for, but oftentimes it's
  not. It's up to you to decide the value of what you are protecting,
  potential attack scenarios and how much security it needs.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;OK, I'm assumming you have a formatted USB drive that you have mounted
on the computer with the LUKS device you want to unlock on boot,
e.g. on &lt;code&gt;/mnt&lt;/code&gt;. Then create a key file with random information:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;# dd if=/dev/urandom of=/mnt/key bs=4096 count=1
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Add the generated key to LUKS, so it can be used to decrypt the root
device:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;# cryptsetup luksAddKey /dev/sda3 /mnt/key
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;&lt;code&gt;/dev/sda3&lt;/code&gt; should be the encrypted partition that holds your root
filesystem.&lt;/p&gt;
&lt;p&gt;Next we will update &lt;code&gt;/etc/crypttab&lt;/code&gt;, which defines encrypted volumes
that can be handled by LUKS. Your &lt;code&gt;crypttab&lt;/code&gt; file should have an entry
similar to this one:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;cryptroot UUID=452ac6ac-8bbb-484f-b508-a11a5585e031 none luks
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;We need to update this line to use the &lt;code&gt;passdev&lt;/code&gt; script provided by
&lt;code&gt;cryptsetup&lt;/code&gt;. This script handles waiting for a device to become
available, then mounting it and reading the key from a file. It's a
good idea to refer to the USB drive using a device that won't change
unexpectedly. I'm using &lt;code&gt;/dev/disk/by-label/&amp;lt;LABEL&amp;gt;&lt;/code&gt;, which is a
symlink to the device based on the partition label. After updating
&lt;code&gt;/etc/crypttab&lt;/code&gt; it should look like this:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;cryptroot UUID=452ac6ac-8bbb-484f-b508-a11a5585e031 /dev/disk/by-label/FIDELRAMOS.NET:/key:20 luks,keyscript=/lib/cryptsetup/scripts/passdev
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;Leave the original mapper name (&lt;code&gt;cryptroot&lt;/code&gt; in this example) and UUID
of your encrypted root, otherwise things will break as the mapper name
is referenced in &lt;code&gt;/etc/fstab&lt;/code&gt;. Just change the &lt;code&gt;none&lt;/code&gt; and add the
keyscript to the options.&lt;/p&gt;
&lt;p&gt;Last step is to regenerate the initramfs image so that it includes the
&lt;code&gt;passdev&lt;/code&gt; script, otherwise the boot process would fail:&lt;/p&gt;
&lt;div class="code-block-wrapper" style="--copy-code-button-bg: #f8f8f2; --copy-code-button-color: #242121"&gt;&lt;div class="highlight"&gt;&lt;pre&gt;&lt;span&gt;&lt;/span&gt;&lt;code&gt;# update-initramfs -k all -u
&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;button aria-label="Copy code to clipboard" class="copy-button"&gt;Copy&lt;/button&gt;&lt;/div&gt;
&lt;p&gt;And that's it! When rebooting the system it should mount the USB drive
automatically and decrypt the root partition using the created key
file. If the USB drive is not present it should fall back to reading
the passphrase from keyboard in 20 seconds.&lt;/p&gt;&lt;div data-button-text="Copied!" hidden="" id="copy-code-settings"&gt;&lt;/div&gt;&lt;link href="https://blog.fidelramos.net/copy_code/copy-code.css" rel="stylesheet"/&gt;&lt;script defer="" src="https://blog.fidelramos.net/copy_code/copy-code.js"&gt;&lt;/script&gt;</content><category term="Software"/><category term="linux"/><category term="encryption"/><category term="security"/><category term="howto"/></entry></feed>