<?xml version='1.0' encoding='UTF-8'?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en" xml:base="https://vincent.bernat.ch">
  <title>Vincent Bernat — Distribution &gt; NixOS</title>
  <link href="https://vincent.bernat.ch/en/blog/atom-distribution-nixos.xml" rel="self"/>
  <link href="https://vincent.bernat.ch/en/blog#tag-distribution-nixos" rel="alternate"/>
  <id>http://www.luffy.cx/en/blog/atom-distribution-nixos.xml/</id>
  <updated>2026-10-20T08:48:37Z</updated>

  <entry>
    <title type="html">Bot-free self-hosted analytics with GoatCounter on NixOS</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/en/blog/2026-goatcounter" rel="alternate"/>
    <link href="https://vincent.bernat.ch/en/blog/2026-goatcounter#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-09-20T19:44:50Z</updated>
    <id>http://www.luffy.cx/en/blog/2026-goatcounter.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>In 2016, I <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/4c3be65404031bf6638c1ea3d891a3ea58e0a0fc" title="layout: remove Google Analytics">removed Google Analytics</a> from this blog to avoid being
complicit in feeding the biggest machine for harvesting personal data. Instead,
I relied on <a href="https://goaccess.io/" title="GoAccess: Visual Web Log Analyzer">GoAccess</a> to analyze my server logs.<sup id="fnref:scrub"><a class="footnote-ref" href="#fn:scrub">1</a></sup> For the past couple
of years, the statistics have made no sense, despite my attempts to filter bots:
<a href="https://herman.bearblog.dev/the-great-scrape/" title="The Great Scrape">AI scrapers</a> inflate the number of visitors to around 2,000 per
day. Eventually, I settled on <a href="https://www.goatcounter.com/" title="GoatCounter: open source web analytics">GoatCounter</a>, an open-source, privacy-friendly
web analytics platform. I replaced the JavaScript client to filter bots more
aggressively and added a CSS fallback. To improve reliability, I implemented a
local proxy running on each of the five web servers serving this blog. The rest
of this post details how these pieces fit together and how I deploy them on
NixOS. ❄️</p>
<div class="toc">
<ul>
<li><a href="#why-goatcounter">Why GoatCounter?</a></li>
<li><a href="#custom-javascript-client">Custom JavaScript client</a></li>
<li><a href="#local-proxy">Local proxy</a></li>
<li><a href="#deploying-on-nixos">Deploying on NixOS</a><ul>
<li><a href="#deploying-applications-in-containers">Deploying applications in containers</a></li>
<li><a href="#goatcounter-server">GoatCounter server</a></li>
<li><a href="#goatcounter-proxy">GoatCounter proxy</a></li>
<li><a href="#backup-of-the-sqlite-database-with-litestream">Backup of the SQLite database with Litestream</a></li>
</ul>
</li>
</ul>
</div>
<h1 id="why-goatcounter">Why GoatCounter?</h1>
<p>GoatCounter does not <a href="https://www.goatcounter.com/help/sessions#technical-details-11181" title="GoatCounter documentation: sessions">collect personal data</a>: instead of storing the reader’s
IP address or relying on cookies, it creates a session identifier valid for 8
hours from the user agent and the IP address. Its feature set is modest but
sufficient for a blog. If you want to look at the interface, GoatCounter’s
author runs a <a href="https://stats.arp242.net" title="Analytics for www.arp242.net">public instance</a> for <a href="https://www.arp242.net" title="Personal website of Martin Tournoij">his site</a>. A hosted
version lets you try it before running your own instance. With a single binary
and an SQLite database, GoatCounter is one of the lightest self-hosted
solutions. Privacy-friendly alternatives, in increasing order of complexity,
include <a href="https://umami.is/" title="Umami: Privacy-First Analytics Platform">Umami</a>, <a href="https://plausible.io/" title="Plausible Analytics: Simple, privacy-friendly Google Analytics alternative">Plausible</a>, and <a href="https://rybbit.com/" title="Rybbit: Cookieless Google Analytics Replacement">Rybbit</a>.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 889px"><span class="lf-media-inner" style="padding-bottom: 96.850%"><img alt="GoatCounter dashboard showing some statistics from my blog, including an&#10;article on the spanning tree protocol with 1,224 views for the past week, the&#10;referrers, and the breakdown of browsers (52% Chrome, 32% Firefox, with 16% for&#10;Firefox 155 and 6% for Firefox 156)" src="https://media-bernat-ch.b-cdn.net/images/goatcounter@1x.a055a7216208d0.png" srcset="https://media-bernat-ch.b-cdn.net/images/goatcounter@1x.a055a7216208d0.png 1117w,https://media-bernat-ch.b-cdn.net/images/goatcounter@2x.12641dd59eef89.png 1778w" sizes="auto, (max-width: 889px) 100vw, 889px" width="889" height="861" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAH8AAAB7AQMAAABn4s1GAAAAA1BMVEX5+Pfi03mPAAAAGElEQVR42mNgGAWjYBSMglEwCkbBKMAJAAgrAAG9UB6fAAAAAElFTkSuQmCC)"/></span></div></figure>
<h1 id="custom-javascript-client">Custom JavaScript client</h1>
<p>GoatCounter includes a <a href="https://github.com/arp242/goatcounter/blob/main/public/count.js" title="GoatCounter's JavaScript client">small JavaScript client</a>—2,189 bytes minified and
gzipped. It ships some features I don’t use: a visitor counter, tracking clicks,
configurable settings, etc. I replace it with this function to register a hit:</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="kd">const</span><span class="w"> </span><span class="nx">count</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">({</span><span class="w"> </span><span class="nx">event</span><span class="p">,</span><span class="w"> </span><span class="nx">title</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{})</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">params</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">URLSearchParams</span><span class="p">({</span>
<span class="w">    </span><span class="nx">p</span><span class="o">:</span><span class="w"> </span><span class="nx">event</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span><span class="p">,</span>
<span class="w">    </span><span class="nx">t</span><span class="o">:</span><span class="w"> </span><span class="nx">title</span><span class="w"> </span><span class="o">||</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">title</span><span class="p">,</span>
<span class="w">    </span><span class="nx">r</span><span class="o">:</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">referrer</span><span class="p">,</span>
<span class="w">    </span><span class="nx">q</span><span class="o">:</span><span class="w"> </span><span class="nx">location</span><span class="p">.</span><span class="nx">search</span><span class="p">,</span>
<span class="w">    </span><span class="nx">s</span><span class="o">:</span><span class="w"> </span><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">clientWidth</span><span class="p">,</span>
<span class="w">    </span><span class="nx">e</span><span class="o">:</span><span class="w"> </span><span class="o">!!</span><span class="nx">event</span><span class="p">,</span>
<span class="w">    </span><span class="nx">rnd</span><span class="o">:</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">random</span><span class="p">().</span><span class="nx">toString</span><span class="p">(</span><span class="mf">36</span><span class="p">).</span><span class="nx">slice</span><span class="p">(</span><span class="mf">2</span><span class="p">,</span><span class="w"> </span><span class="mf">7</span><span class="p">),</span>
<span class="w">  </span><span class="p">});</span>
<span class="w">  </span><span class="nx">fetch</span><span class="p">(</span><span class="sb">`/count?</span><span class="si">${</span><span class="nx">params</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">keepalive</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span><span class="p">}).</span><span class="k">catch</span><span class="p">(()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{});</span>
<span class="p">};</span>
</code></pre></div>

<p>To filter bots,<sup id="fnref:bot-filter"><a class="footnote-ref" href="#fn:bot-filter">2</a></sup> I go the extra mile by requiring a user
interaction—an idea I stole from <a href="https://bearblog.dev/" title="Bear Blog">Bear Blog</a>.</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="kd">let</span><span class="w"> </span><span class="nx">sendHit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">(</span><span class="nx">sendHit</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">()</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="p">{},</span><span class="w"> </span><span class="nx">count</span><span class="p">());</span>
<span class="p">[</span><span class="s2">"touchmove"</span><span class="p">,</span><span class="w"> </span><span class="s2">"mousemove"</span><span class="p">,</span><span class="w"> </span><span class="s2">"keydown"</span><span class="p">,</span><span class="w"> </span><span class="s2">"pointerdown"</span><span class="p">].</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">eventName</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span>
<span class="w">  </span><span class="nb">document</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="nx">eventName</span><span class="p">,</span><span class="w"> </span><span class="nx">sendHit</span><span class="p">,</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nx">once</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">    </span><span class="nx">passive</span><span class="o">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">  </span><span class="p">}),</span>
<span class="p">);</span>
</code></pre></div>

<p>If a reader has disabled JavaScript in their browser, I record the hit using a
CSS image. The <code>:hover</code> pseudo-class loads it only after an interaction, another
trick <a href="https://herman.bearblog.dev/how-bear-does-analytics-with-css/" title="How Bear does analytics with CSS">stolen from Bear Blog</a>. About 2% of my visitors fit into this
bucket.<sup id="fnref:referrer"><a class="footnote-ref" href="#fn:referrer">3</a></sup></p>
<div class="language-html codehilite"><pre><span/><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="p">&lt;</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">"en"</span> <span class="na">class</span><span class="o">=</span><span class="s">"nojs"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">head</span><span class="p">&gt;</span>
    <span class="p">&lt;</span><span class="nt">script</span><span class="p">&gt;</span>
<span class="w">      </span><span class="c1">// The JavaScript code for this blog requires ES6</span>
<span class="w">      </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="s2">"noModule"</span><span class="w"> </span><span class="ow">in</span><span class="w"> </span><span class="nx">HTMLScriptElement</span><span class="p">.</span><span class="nx">prototype</span><span class="p">)</span>
<span class="w">        </span><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nx">remove</span><span class="p">(</span><span class="s2">"nojs"</span><span class="p">);</span>
<span class="w">    </span><span class="p">&lt;/</span><span class="nt">script</span><span class="p">&gt;</span>
  <span class="p">&lt;/</span><span class="nt">head</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">body</span><span class="p">&gt;</span>
  <span class="cm">&lt;!-- ... --&gt;</span>
    <span class="p">&lt;</span><span class="nt">style</span><span class="p">&gt;</span>
<span class="w">      </span><span class="p">.</span><span class="nc">nojs</span><span class="w"> </span><span class="nt">body</span><span class="p">:</span><span class="nd">hover</span><span class="w"> </span><span class="p">{</span>
<span class="w">        </span><span class="k">border-width</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w">        </span><span class="k">border-image</span><span class="p">:</span><span class="w"> </span><span class="nb">url</span><span class="p">(</span><span class="s1">'/count?p=/en/blog/2026-kpi-goodhart&amp;t=Building...&amp;r=NoJS&amp;e=false'</span><span class="p">);</span>
<span class="w">      </span><span class="p">}</span>
<span class="w">    </span><span class="p">&lt;/</span><span class="nt">style</span><span class="p">&gt;</span>
  <span class="p">&lt;/</span><span class="nt">body</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">html</span><span class="p">&gt;</span>
</code></pre></div>

<p>Where GoAccess reported around 2,000 visitors a day, GoatCounter counts fewer
than 200 humans.<sup id="fnref:rss"><a class="footnote-ref" href="#fn:rss">4</a></sup> I assume AI scrapers use a low-effort approach: if the
content is available without barriers, as on this blog, they don’t spawn a
complex mechanized browser that could trigger a page view. Even crawlers running
JavaScript, like <a href="https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics" title="Google Search Central: Understand the JavaScript SEO basics">Googlebot with its headless Chromium</a>, <a href="https://developers.google.com/search/docs/crawling-indexing/javascript/lazy-loading#:~:text=Google%20Search%20does%20not%20interact%20with%20your%20page" title="Google Search Central: Fix lazy-loaded content">do not
interact with the page</a> and never trigger the events I listen
to. The interaction-based “proof of humanity” I use is likely to keep
working.<sup id="fnref:css-test"><a class="footnote-ref" href="#fn:css-test">5</a></sup></p>
<h1 id="local-proxy">Local proxy</h1>
<p>Five servers <del>across the world</del> <ins>in Europe and in North
America</ins> serve the content of this website, but GoatCounter runs on only
one of them. To avoid losing track of visitors when GoatCounter is down, I run a
local proxy listening on the same <code>/count</code> endpoint. On each server, it stores
the hits in memory with a buffer large enough to survive several days of
downtime. It sends them in batches to the upstream backend using the
<a href="https://www.goatcounter.com/help/api" title="GoatCounter documentation: API"><code>/api/v0/count</code> authenticated endpoint</a>.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 836px"><span class="lf-media-inner" style="padding-bottom: 58.852%"><object width="836" height="492" type="image/svg+xml" data="https://media-bernat-ch.b-cdn.net/images/luffy-web-servers.be5e1b31615943.svg" class="lf-media">🖼 Servers on a map. web02 is in Paris, web03 in Helsinki, web04 in Nuremberg,
web05 in Ashburn, web06 in Chicago.</object></span></div></figure>
<p>I proposed the code for the proxy in <a href="https://github.com/arp242/goatcounter/pull/909" title="PR #909: add a proxy to forward page counts">pull request #909</a>. GoatCounter’s
maintainer declined to maintain so much code for such a niche use case. As a
fellow open-source developer, I often hold the same position for my own
projects: a one-time contributor effort may translate into a long-term
maintainer commitment.</p>
<p>I expose the endpoint for the proxy on the domain of this website to evade ad
blockers. This sounds like I don’t respect the reader’s choice, but as
GoatCounter is privacy-friendly, I find it acceptable.</p>
<div class="language-nginx-configuration-file codehilite"><pre><span/><code><span class="k">location</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">/count</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">access_log</span><span class="w"> </span><span class="no">off</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_pass</span><span class="w"> </span><span class="s">http://127.0.0.3:8087/count</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_pass_request_headers</span><span class="w"> </span><span class="no">off</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Accept-Language</span><span class="w"> </span><span class="nv">$http_accept_language</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">User-Agent</span><span class="w"> </span><span class="nv">$http_user_agent</span><span class="p">;</span>
<span class="w">  </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">X-Real-Ip</span><span class="w"> </span><span class="nv">$remote_addr</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<h1 id="deploying-on-nixos">Deploying on NixOS</h1>
<p>My web servers run <a href="https://nixos.org/" title="Nix &amp; NixOS: Declarative builds and deployments">NixOS</a>, a declarative Linux distribution with built-in
configuration management. I manage this small fleet with <a href="https://colmena.cli.rs/" title="Colmena documentation">Colmena</a>, a
stateless deployment tool for NixOS. My configuration is available on
<a href="https://github.com/vincentbernat/nixops-take1/" title="vincentbernat/nixops-take1: Playground for NixOps">GitHub</a>.</p>
<h2 id="deploying-applications-in-containers">Deploying applications in containers</h2>
<p>For better isolation, each application runs inside an ephemeral lightweight
container, powered by <a href="https://manpages.debian.org/systemd-nspawn.1.html" title="Manual page for systemd-nspawn(1)">systemd-nspawn</a>. Each container runs a stripped-down
NixOS instance. A <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/modules/container.nix" title="nixops-take1/modules/container.nix">module wraps NixOS’s <code>containers</code> options</a> to
avoid repeating the same options for each application.<sup id="fnref:module"><a class="footnote-ref" href="#fn:module">6</a></sup> The containers
share their network namespace with the host: the additional isolation is not
worth the increased complexity. For a smaller footprint, I also disable a few
non-essential services.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  <span class="c1"># User-configurable settings for our custom module</span>
  options<span class="o">.</span>luffy<span class="o">.</span><span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
    <span class="ss">default</span> <span class="o">=</span> <span class="p">{</span> <span class="p">};</span>
    <span class="ss">description</span> <span class="o">=</span> <span class="s2">"Ephemeral containers sharing the host network."</span><span class="p">;</span>
    <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>submodule <span class="p">{</span>
      <span class="ss">options</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">config</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
          <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>deferredModule<span class="p">;</span>
          <span class="ss">default</span> <span class="o">=</span> <span class="p">{</span> <span class="p">};</span>
          <span class="ss">description</span> <span class="o">=</span> <span class="s2">"NixOS configuration of the container."</span><span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">});</span>
  <span class="p">};</span>

  <span class="c1"># Translate our options to NixOS containers</span>
  <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span> <span class="p">{</span>
<span class="hll">        <span class="ss">ephemeral</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
</span>        <span class="ss">autoStart</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
<span class="hll">        <span class="ss">privateNetwork</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span>        <span class="ss">extraFlags</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"--resolv-conf=replace-host"</span> <span class="p">];</span>
        <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">          <span class="ss">imports</span> <span class="o">=</span> <span class="p">[</span> container<span class="o">.</span>config <span class="p">];</span>
</span>          networking<span class="o">.</span>firewall<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
          system<span class="o">.</span><span class="ss">stateVersion</span> <span class="o">=</span> config<span class="o">.</span>system<span class="o">.</span>stateVersion<span class="p">;</span>
          systemd<span class="o">.</span><span class="ss">services</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">            console-getty<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span><span class="hll">            systemd-logind<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span><span class="hll">            systemd-oomd<span class="o">.</span><span class="ss">enable</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
</span>          <span class="p">};</span>
        <span class="p">};</span>
      <span class="p">})</span>
      cfg<span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>To configure a GoatCounter instance running in a container and listening on
<code>127.0.0.4:8088</code>, we import the module<sup id="fnref:import"><a class="footnote-ref" href="#fn:import">7</a></sup> and declare the container in the
<code>config.luffy.containers</code> attribute set:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> pkgs<span class="p">,</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span> <span class="p">{</span>
  <span class="ss">imports</span> <span class="o">=</span> <span class="p">[</span> <span class="l">./modules/container.nix</span> <span class="p">];</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">address</span> <span class="o">=</span> <span class="s2">"127.0.0.4"</span><span class="p">;</span>
        <span class="ss">port</span> <span class="o">=</span> <span class="mi">8088</span><span class="p">;</span>
        <span class="ss">proxy</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>As the containers are ephemeral, we need to keep persistent data in directories
on the host. We add a <code>mounts</code> option and ask NixOS’s containers to expose the
configured directories through the <code>bindMounts</code> option.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  options<span class="o">.</span>luffy<span class="o">.</span><span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
    <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>submodule <span class="p">{</span>
      <span class="ss">options</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">mounts</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
          <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>listOf lib<span class="o">.</span>types<span class="o">.</span>str<span class="p">;</span>
          <span class="ss">default</span> <span class="o">=</span> <span class="p">[</span> <span class="p">];</span>
          <span class="ss">description</span> <span class="o">=</span> <span class="s2">"Host directories mounted read-write at the same place."</span><span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">});</span>
  <span class="p">};</span>

  <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span> <span class="p">{</span>
        <span class="ss">bindMounts</span> <span class="o">=</span>
          lib<span class="o">.</span>genAttrs container<span class="o">.</span>mounts <span class="p">(</span>path<span class="p">:</span> <span class="p">{</span> <span class="ss">hostPath</span> <span class="o">=</span> path<span class="p">;</span> <span class="ss">isReadOnly</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span> <span class="p">});</span>
      <span class="p">})</span>
      cfg<span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>For example, to persist GoatCounter’s database in the <code>/var/db/goatcounter</code>
directory on the host, we add the directory to the <code>mounts</code> option and alter the
service definition to tell GoatCounter where the database is.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">databaseDirectory</span> <span class="o">=</span> <span class="s2">"/var/db/goatcounter"</span><span class="p">;</span>
<span class="k">in</span> <span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">mounts</span> <span class="o">=</span> <span class="p">[</span> databaseDirectory <span class="p">];</span>
    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">extraArgs</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"-db=sqlite+</span><span class="si">${</span>databaseDirectory<span class="si">}</span><span class="s2">/db.sqlite"</span> <span class="p">];</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>A container may also need some secrets. Colmena can <a href="https://colmena.cli.rs/unstable/features/keys.html" title="Colmena documentation: Secrets">upload secrets</a> without
storing them in the Nix store. We add a <code>keys</code> option to our containers. It
takes an attribute set mapping secret names to the commands to populate them.
Then, the module declares the required secrets to Colmena in the
<code>deployment.keys</code> option, makes the container depend on the presence of the
secrets, and exposes them to the container.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  options<span class="o">.</span>luffy<span class="o">.</span><span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
    <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>submodule <span class="p">{</span>
      <span class="ss">options</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">keys</span> <span class="o">=</span> lib<span class="o">.</span>mkOption <span class="p">{</span>
          <span class="ss">type</span> <span class="o">=</span> lib<span class="o">.</span>types<span class="o">.</span>attrsOf <span class="p">(</span>lib<span class="o">.</span>types<span class="o">.</span>listOf lib<span class="o">.</span>types<span class="o">.</span>str<span class="p">);</span>
          <span class="ss">default</span> <span class="o">=</span> <span class="p">{</span> <span class="p">};</span>
          <span class="ss">description</span> <span class="o">=</span> <span class="s2">"Secrets, as a command to run locally. They are mounted in /etc."</span><span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">});</span>
  <span class="p">};</span>

  <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="c1"># Colmena uploads each secret in `/var/keys` and make them available</span>
    <span class="c1"># to the group "keys".</span>
    deployment<span class="o">.</span><span class="ss">keys</span> <span class="o">=</span> lib<span class="o">.</span>concatMapAttrs
      <span class="p">(</span>_<span class="p">:</span> container<span class="p">:</span> lib<span class="o">.</span>mapAttrs
        <span class="p">(</span>_<span class="p">:</span> keyCommand<span class="p">:</span> <span class="p">{</span>
          <span class="k">inherit</span> keyCommand<span class="p">;</span>
          <span class="ss">group</span> <span class="o">=</span> <span class="s2">"keys"</span><span class="p">;</span>
          <span class="ss">permissions</span> <span class="o">=</span> <span class="s2">"0640"</span><span class="p">;</span>
          <span class="ss">destDir</span> <span class="o">=</span> <span class="s2">"/var/keys"</span><span class="p">;</span>
        <span class="p">})</span>
        container<span class="o">.</span>keys<span class="p">)</span>
      cfg<span class="p">;</span>

    <span class="c1"># The container can only start if the required secrets are available.</span>
    systemd<span class="o">.</span><span class="ss">services</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs'
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span>
        <span class="k">let</span>
          <span class="ss">units</span> <span class="o">=</span> <span class="nb">map</span> <span class="p">(</span>key<span class="p">:</span> <span class="s2">"</span><span class="si">${</span>key<span class="si">}</span><span class="s2">-key.service"</span><span class="p">)</span> <span class="p">(</span>lib<span class="o">.</span>attrNames container<span class="o">.</span>keys<span class="p">);</span>
        <span class="k">in</span>
        lib<span class="o">.</span>nameValuePair <span class="s2">"container@</span><span class="si">${</span>name<span class="si">}</span><span class="s2">"</span> <span class="p">{</span>
          <span class="ss">requires</span> <span class="o">=</span> units<span class="p">;</span>
          <span class="ss">after</span> <span class="o">=</span> units<span class="p">;</span>
        <span class="p">})</span>
      cfg<span class="p">;</span>

    <span class="c1"># Mount each secret inside the container.</span>
    <span class="ss">containers</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs
      <span class="p">(</span>name<span class="p">:</span> container<span class="p">:</span> <span class="p">{</span>
        <span class="ss">bindMounts</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrs'
          <span class="p">(</span>key<span class="p">:</span> _<span class="p">:</span> lib<span class="o">.</span>nameValuePair <span class="s2">"/etc/</span><span class="si">${</span>key<span class="si">}</span><span class="s2">"</span> <span class="p">{</span>
            <span class="ss">hostPath</span> <span class="o">=</span> <span class="s2">"/var/keys/</span><span class="si">${</span>key<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
            <span class="ss">isReadOnly</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
          <span class="p">})</span>
          container<span class="o">.</span>keys<span class="p">;</span>
      <span class="p">})</span>
      cfg<span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>For example, GoatCounter needs credentials to download the GeoIP database. I
provide a local command to fetch the secret from my password manager and expose
it inside the container through the <code>/etc/goatcounter.env</code> environment file.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> pkgs<span class="p">,</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span> 
<span class="k">let</span>
  <span class="ss">keyCommand</span> <span class="o">=</span> variable<span class="p">:</span> <span class="p">[</span>
    <span class="s2">"</span><span class="si">${</span>pkgs<span class="o">.</span>runtimeShell<span class="si">}</span><span class="s2">"</span>
    <span class="s2">"-c"</span>
    <span class="s2">"pass show personal/nixops/secrets | grep '^</span><span class="si">${</span>variable<span class="si">}</span><span class="s2">='"</span>
  <span class="p">];</span>
<span class="k">in</span> <span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">    keys<span class="o">.</span><span class="s2">"goatcounter.env"</span> <span class="o">=</span> keyCommand <span class="s2">"GOATCOUNTER_GEODB"</span><span class="p">;</span>
</span>    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      systemd<span class="o">.</span>services<span class="o">.</span>goatcounter<span class="o">.</span><span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">EnvironmentFile</span> <span class="o">=</span> <span class="s2">"/etc/goatcounter.env"</span><span class="p">;</span>
        <span class="ss">SupplementaryGroups</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"keys"</span> <span class="p">];</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<h2 id="goatcounter-server">GoatCounter server</h2>
<p>Nixpkgs already packages GoatCounter. By overriding the <code>src</code> and <code>vendorHash</code>
attributes, I reuse its definition for my custom version with the proxy:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> goatcounter<span class="p">,</span> fetchFromGitHub <span class="p">}:</span>
goatcounter<span class="o">.</span>overrideAttrs <span class="p">(</span>_<span class="p">:</span> <span class="p">{</span>
  <span class="ss">src</span> <span class="o">=</span> fetchFromGitHub <span class="p">{</span>
    <span class="ss">owner</span> <span class="o">=</span> <span class="s2">"vincentbernat"</span><span class="p">;</span>
    <span class="ss">repo</span> <span class="o">=</span> <span class="s2">"goatcounter"</span><span class="p">;</span>
    <span class="ss">rev</span> <span class="o">=</span> <span class="s2">"feature/proxy"</span><span class="p">;</span>
    <span class="ss">hash</span> <span class="o">=</span> <span class="s2">"sha256-dJRlQlFu3tjcEgabT1LEbyFrasJlhmYu4L/T7EkoNcY="</span><span class="p">;</span>
  <span class="p">};</span>
  <span class="ss">vendorHash</span> <span class="o">=</span> <span class="s2">"sha256-c9Q5OrbZR+q6pD3SgPPWe8JUzcZco1AVUKGaV61k5DE="</span><span class="p">;</span>
<span class="p">})</span>
</code></pre></div>

<p>I wrote a <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/modules/goatcounter.nix" title="nixops-take1/modules/goatcounter.nix">NixOS module</a> to encapsulate GoatCounter: the
container definition, the service definition, and the secrets. The module
accepts the following options: <code>package</code>, <code>serve.enable</code>, <code>serve.listenAddress</code>,
<code>serve.port</code>, and <code>serve.databaseFile</code>. I already detailed the container
configuration in the previous section. In the end, I chose not to reuse the
GoatCounter module from NixOS: it’s small, so it’s better to insulate my module
from unexpected future changes.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="p">;</span>
  <span class="ss">databaseDirectory</span> <span class="o">=</span> <span class="nb">builtins</span><span class="o">.</span><span class="nb">dirOf</span> cfg<span class="o">.</span>serve<span class="o">.</span>databaseFile<span class="p">;</span>
  <span class="ss">chown</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span>pkgs<span class="o">.</span>coreutils<span class="si">}</span><span class="s2">/bin/chown -R"</span><span class="p">;</span>
<span class="k">in</span> <span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    config<span class="o">.</span>systemd<span class="o">.</span>services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">description</span> <span class="o">=</span> <span class="s2">"GoatCounter Web Analytics"</span><span class="p">;</span>
      <span class="ss">wantedBy</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"multi-user.target"</span> <span class="p">];</span>
      <span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">EnvironmentFile</span> <span class="o">=</span> <span class="s2">"/etc/goatcounter.env"</span><span class="p">;</span>
        <span class="ss">SupplementaryGroups</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"keys"</span> <span class="p">];</span>
        <span class="ss">DynamicUser</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">Restart</span> <span class="o">=</span> <span class="s2">"always"</span><span class="p">;</span>
        <span class="ss">ExecStart</span> <span class="o">=</span> lib<span class="o">.</span>escapeShellArgs <span class="p">[</span>
          <span class="p">(</span>lib<span class="o">.</span>getExe cfg<span class="o">.</span>package<span class="p">)</span>
          <span class="s2">"serve"</span>
          <span class="s2">"-listen=</span><span class="si">${</span>cfg<span class="o">.</span>serve<span class="o">.</span>listenAddress<span class="si">}</span><span class="s2">:</span><span class="si">${</span><span class="nb">toString</span> cfg<span class="o">.</span>serve<span class="o">.</span>port<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-tls=none"</span>
          <span class="s2">"-db=sqlite+</span><span class="si">${</span>cfg<span class="o">.</span>serve<span class="o">.</span>databaseFile<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-automigrate"</span>
        <span class="p">];</span>
        <span class="c1"># Transfer database ownership to dynamically assigned user "goatcounter".</span>
        <span class="ss">ExecStartPre</span> <span class="o">=</span> <span class="s2">"+</span><span class="si">${</span>chown<span class="si">}</span><span class="s2"> goatcounter:goatcounter </span><span class="si">${</span>databaseDirectory<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
        <span class="ss">ReadWritePaths</span> <span class="o">=</span> databaseDirectory<span class="p">;</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>The following snippet configures GoatCounter to listen on <code>127.0.0.4:8088</code>:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span>
  luffy<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">serve</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
      <span class="ss">listenAddress</span> <span class="o">=</span> <span class="s2">"127.0.0.4"</span><span class="p">;</span>
      <span class="ss">port</span> <span class="o">=</span> <span class="mi">8088</span><span class="p">;</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>The last step is to configure nginx to expose GoatCounter on the Internet. I
disable the <code>/count</code> endpoint as the local proxy handles it.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="o">.</span>serve<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  services<span class="o">.</span>nginx<span class="o">.</span>virtualHosts<span class="o">.</span><span class="s2">"goatcounter.luffy.cx"</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">forceSSL</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
    <span class="ss">locations</span> <span class="o">=</span> <span class="p">{</span>
      <span class="s2">"/"</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">proxyPass</span> <span class="o">=</span> <span class="s2">"http://</span><span class="si">${</span>cfg<span class="o">.</span>listenAddress<span class="si">}</span><span class="s2">:</span><span class="si">${</span><span class="nb">toString</span> cfg<span class="o">.</span>port<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
      <span class="p">};</span>
      <span class="s2">"= /count"</span><span class="o">.</span><span class="ss">extraConfig</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">        return 404;</span>
<span class="s s-Multiline">      ''</span><span class="p">;</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<h2 id="goatcounter-proxy">GoatCounter proxy</h2>
<p>The same <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/modules/goatcounter.nix" title="nixops-take1/modules/goatcounter.nix">NixOS module</a> configures the local proxy, with the
following options: <code>proxy.enable</code>, <code>proxy.listenAddress</code>, <code>proxy.port</code>, and
<code>proxy.site</code>—the site receiving the batches of page views. The local proxy has
no persistent data, but it needs the API key to authenticate to the main
GoatCounter instance: its container uses the <code>keys</code> option but not the <code>mounts</code>
option.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="p">;</span>
  <span class="ss">keyCommand</span> <span class="o">=</span> _<span class="p">:</span> <span class="p">[</span> <span class="s2">"…"</span> <span class="p">];</span>
<span class="k">in</span>
<span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">goatcounter-proxy</span> <span class="o">=</span> <span class="p">{</span>
<span class="hll">    keys<span class="o">.</span><span class="s2">"goatcounter-proxy.env"</span> <span class="o">=</span> keyCommand <span class="s2">"GOATCOUNTER_API_KEY"</span><span class="p">;</span>
</span>    config<span class="o">.</span>systemd<span class="o">.</span>services<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">description</span> <span class="o">=</span> <span class="s2">"GoatCounter Proxy."</span><span class="p">;</span>
      <span class="ss">wantedBy</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"multi-user.target"</span> <span class="p">];</span>
      <span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">EnvironmentFile</span> <span class="o">=</span> <span class="s2">"/etc/goatcounter-proxy.env"</span><span class="p">;</span>
        <span class="ss">SupplementaryGroups</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"keys"</span> <span class="p">];</span>
        <span class="ss">DynamicUser</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">Restart</span> <span class="o">=</span> <span class="s2">"always"</span><span class="p">;</span>
        <span class="ss">ExecStart</span> <span class="o">=</span> lib<span class="o">.</span>escapeShellArgs <span class="p">[</span>
          <span class="p">(</span>lib<span class="o">.</span>getExe cfg<span class="o">.</span>package<span class="p">)</span>
          <span class="s2">"proxy"</span>
          <span class="s2">"-site=</span><span class="si">${</span>cfg<span class="o">.</span>proxy<span class="o">.</span>site<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-listen=</span><span class="si">${</span>cfg<span class="o">.</span>proxy<span class="o">.</span>listenAddress<span class="si">}</span><span class="s2">:</span><span class="si">${</span><span class="nb">toString</span> cfg<span class="o">.</span>proxy<span class="o">.</span>port<span class="si">}</span><span class="s2">"</span>
          <span class="s2">"-ratelimit=10/1"</span>  <span class="c1"># 10 requests per second per IP</span>
        <span class="p">];</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>For each server, I enable the local proxy with the following snippet. The
nginx configuration shown earlier exposes the <code>/count</code> endpoint under the same
domain as my blog.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span>
  luffy<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">proxy</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
      <span class="ss">site</span> <span class="o">=</span> <span class="s2">"goatcounter.luffy.cx"</span><span class="p">;</span>
      <span class="ss">listenAddress</span> <span class="o">=</span> <span class="s2">"127.0.0.3"</span><span class="p">;</span>
      <span class="ss">port</span> <span class="o">=</span> <span class="mi">8087</span><span class="p">;</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<h2 id="backup-of-the-sqlite-database-with-litestream">Backup of the SQLite database with Litestream</h2>
<p><a href="https://litestream.io/" title="Litestream: Streaming SQLite Replication">Litestream</a> is a streaming replication tool for SQLite databases. It
compresses the changes committed to the write-ahead log (<abbr title="Write-ahead log">WAL</abbr>) next to the
database and sends them to a remote destination. I encapsulate its configuration
in a <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/modules/litestream.nix" title="nixops-take1/modules/litestream.nix">NixOS module</a>, which takes an attribute set <code>databases</code>
mapping a name to the path of the database to back up.</p>
<p>Litestream also runs in a container. I mount the databases to replicate, as well
as the secrets to push the backups to a <a href="https://www.hetzner.com/storage/storage-box/" title="Hetzner Storage Box">Hetzner storage box</a> using <abbr title="Secure File Transfer Protocol">SFTP</abbr>:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>litestream<span class="p">;</span>
  <span class="ss">databaseDirs</span> <span class="o">=</span> lib<span class="o">.</span>unique <span class="p">(</span><span class="nb">map</span> <span class="nb">builtins</span><span class="o">.</span><span class="nb">dirOf</span> <span class="p">(</span><span class="nb">builtins</span><span class="o">.</span>attrValues cfg<span class="o">.</span>databases<span class="p">));</span>
<span class="k">in</span>
<span class="p">{</span>
  <span class="ss">config</span> <span class="o">=</span> lib<span class="o">.</span>mkIf <span class="p">(</span>cfg<span class="o">.</span>databases <span class="o">!=</span> <span class="p">{</span> <span class="p">})</span> <span class="p">{</span>
    luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">litestream</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">mounts</span> <span class="o">=</span> databaseDirs<span class="p">;</span>
      keys<span class="o">.</span><span class="s2">"litestream.env"</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s2">"</span><span class="si">${</span>pkgs<span class="o">.</span>runtimeShell<span class="si">}</span><span class="s2">"</span>
        <span class="s2">"-c"</span>
        <span class="s2">"pass show personal/nixops/secrets | grep '^SQLITE_BACKUP_'"</span>
      <span class="p">];</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>Inside the container, I configure Litestream through NixOS’s
<code>services.litestream</code> options:</p>
<ul>
<li>full snapshots every day, kept for 15 days,</li>
<li>three <a href="https://fly.io/blog/litestream-v050-is-here/" title="Litestream v0.5.0 is Here">levels of compaction</a> for transaction files: 5 minutes, 30 minutes, and 3 hours,</li>
<li>auto-recovery,<sup id="fnref:auto-recover"><a class="footnote-ref" href="#fn:auto-recover">8</a></sup></li>
<li>replica stored in a directory matching the host name, and</li>
<li>credentials read from <code>/etc/litestream.env</code> and exposed through variable expansion.</li>
</ul>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> pkgs<span class="p">,</span> lib<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>litestream<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  config<span class="o">.</span>luffy<span class="o">.</span>containers<span class="o">.</span><span class="ss">litestream</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
      <span class="c1"># The databases belong to dynamically allocated users, whose UID is</span>
      <span class="c1"># not known here, so Litestream runs as root.</span>
      systemd<span class="o">.</span>services<span class="o">.</span>litestream<span class="o">.</span><span class="ss">serviceConfig</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">User</span> <span class="o">=</span> lib<span class="o">.</span>mkForce <span class="s2">"root"</span><span class="p">;</span>
        <span class="ss">Group</span> <span class="o">=</span> lib<span class="o">.</span>mkForce <span class="s2">"root"</span><span class="p">;</span>
      <span class="p">};</span>
      <span class="c1"># Use NixOS service.</span>
      services<span class="o">.</span><span class="ss">litestream</span> <span class="o">=</span> <span class="p">{</span>
        <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
        <span class="ss">environmentFile</span> <span class="o">=</span> <span class="s2">"/etc/litestream.env"</span><span class="p">;</span>
        <span class="ss">settings</span> <span class="o">=</span> <span class="p">{</span>
          <span class="ss">auto-recover</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
          <span class="ss">snapshot</span> <span class="o">=</span> <span class="p">{</span>
            <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"24h"</span><span class="p">;</span>
            <span class="ss">retention</span> <span class="o">=</span> <span class="s2">"360h"</span><span class="p">;</span>
          <span class="p">};</span>
          <span class="ss">levels</span> <span class="o">=</span> <span class="p">[</span>
            <span class="p">{</span> <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"5m"</span><span class="p">;</span> <span class="p">}</span>
            <span class="p">{</span> <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"30m"</span><span class="p">;</span> <span class="p">}</span>
            <span class="p">{</span> <span class="ss">interval</span> <span class="o">=</span> <span class="s2">"3h"</span><span class="p">;</span> <span class="p">}</span>
          <span class="p">];</span>
          <span class="ss">dbs</span> <span class="o">=</span> lib<span class="o">.</span>mapAttrsToList
            <span class="p">(</span>name<span class="p">:</span> path<span class="p">:</span> <span class="p">{</span>
              <span class="k">inherit</span> path<span class="p">;</span>
              <span class="ss">replica</span> <span class="o">=</span> <span class="p">{</span>
                <span class="ss">type</span> <span class="o">=</span> <span class="s2">"sftp"</span><span class="p">;</span>
                <span class="ss">host</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_HOST}"</span><span class="p">;</span>
                <span class="ss">user</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_USER}"</span><span class="p">;</span>
                <span class="ss">password</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_PASSWORD}"</span><span class="p">;</span>
                <span class="ss">host-key</span> <span class="o">=</span> <span class="s2">"</span><span class="se">\$</span><span class="s2">{SQLITE_BACKUP_HOSTKEY}"</span><span class="p">;</span>
                <span class="ss">path</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span>config<span class="o">.</span>networking<span class="o">.</span>hostName<span class="si">}</span><span class="s2">/</span><span class="si">${</span>name<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
              <span class="p">};</span>
            <span class="p">})</span>
            cfg<span class="o">.</span>databases<span class="p">;</span>
        <span class="p">};</span>
      <span class="p">};</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>To back up GoatCounter’s database, I declare a <code>goatcounter</code> attribute in
<code>luffy.litestream.databases</code>, set to the database path:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> config<span class="p">,</span> <span class="o">...</span> <span class="p">}:</span>
<span class="k">let</span>
  <span class="ss">cfg</span> <span class="o">=</span> config<span class="o">.</span>luffy<span class="o">.</span>goatcounter<span class="o">.</span>serve<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  luffy<span class="o">.</span>litestream<span class="o">.</span>databases<span class="o">.</span><span class="ss">goatcounter</span> <span class="o">=</span> cfg<span class="o">.</span>databaseFile<span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>On the <abbr title="Secure File Transfer Protocol">SFTP</abbr> server, we can inspect Litestream’s work, with the compacted
transactions and the full snapshots:</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">❯ </span>ls<span class="w"> </span>web02/goatcounter/ltx
<span class="go">web02/goatcounter/ltx/0</span>
<span class="go">web02/goatcounter/ltx/1</span>
<span class="go">web02/goatcounter/ltx/2</span>
<span class="go">web02/goatcounter/ltx/3</span>
<span class="go">web02/goatcounter/ltx/9</span>
<span class="gp">❯ </span>ls<span class="w"> </span>-lh<span class="w"> </span>web02/goatcounter/ltx/1
<span class="go">29.1K Sep  5 01:25 0000000000003f2a-0000000000003f2b.ltx</span>
<span class="go">72.4K Sep  5 02:03 0000000000003f2c-0000000000003f2d.ltx</span>
<span class="go">63.3K Sep  5 02:24 0000000000003f2e-0000000000003f2f.ltx</span>
<span class="go">[…]</span>
<span class="gp">❯ </span>ls<span class="w"> </span>-lh<span class="w"> </span>web02/goatcounter/ltx/9
<span class="go"> 8.5M Sep  5 02:00 0000000000000001-0000000000003f2b.ltx</span>
<span class="go"> 8.5M Sep  6 02:03 0000000000000001-0000000000004008.ltx</span>
<span class="go"> 8.6M Sep  7 02:03 0000000000000001-00000000000043a8.ltx</span>
<span class="go">[…]</span>
</code></pre></div>

<p>We can restore the database from the backup with a few shell commands. First, we
stop the containers. Then, we move the damaged database away, invoke <code>litestream
restore</code> from the right environment, and restart the containers.<sup id="fnref:slow"><a class="footnote-ref" href="#fn:slow">9</a></sup></p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp"># </span>systemctl<span class="w"> </span>stop<span class="w"> </span>container@goatcounter<span class="w"> </span>container@litestream
<span class="gp"># </span>mv<span class="w"> </span>/var/db/goatcounter/db.sqlite<span class="o">{</span>,.old<span class="o">}</span>
<span class="gp"># </span><span class="o">(</span><span class="w"> </span>.<span class="w"> </span>/etc/nixos-containers/litestream.conf<span class="w"> </span><span class="p">;</span><span class="w"> </span>
<span class="gp">&gt; </span><span class="w">  </span><span class="nb">set</span><span class="w"> </span>-a<span class="w"> </span><span class="p">;</span><span class="w"> </span>.<span class="w"> </span>/var/keys/litestream.env<span class="w"> </span><span class="p">;</span><span class="w"> </span><span class="nb">set</span><span class="w"> </span>+a<span class="w"> </span><span class="p">;</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="nv">$SYSTEM_PATH</span>/sw/bin/litestream<span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">    </span>restore<span class="w"> </span>-config<span class="w"> </span><span class="nv">$SYSTEM_PATH</span>/etc/litestream.yml<span class="w"> </span>/var/db/goatcounter/db.sqlite<span class="o">)</span>
<span class="gp"># </span>ls<span class="w"> </span>-lh<span class="w"> </span>/var/db/goatcounter/db.sqlite
<span class="go">-rw-r--r-- 1 root root 20M Sep 20 07:33 /var/db/goatcounter/db.sqlite</span>
<span class="gp"># </span>systemctl<span class="w"> </span>start<span class="w"> </span>container@goatcounter<span class="w"> </span>container@litestream
</code></pre></div>

<hr/>
<p>Ten years after <a href="/en/blog/2018-more-privacy-blog" title="A more privacy-friendly blog">removing Google Analytics</a>, JavaScript-based analytics is
back on this blog, but without storing cookies or IP addresses, and without
involving a third party. I still write for myself first, notably because it lets
me dig into a topic and refer back to it years later. But knowing a bit more
about my fellow human readers is a nice bonus, even the ones disabling
JavaScript. 🐐</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:scrub">
<p>Nginx scrambles IP addresses before storing them, thanks to the <a href="https://github.com/masonicboom/ipscrub" title="ipscrub: IP address anonymizer module for nginx"><code>ipscrub</code>
nginx module</a>. <a class="footnote-backref" href="#fnref:scrub" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:bot-filter">
<p>GoatCounter already filters some bots based on the user agent or the IP
address. But AI scrapers lie about their user agent and hide behind
<a href="https://jacob.gold/posts/residential-proxies-are-a-national-security-threat/" title="Residential Proxies Are a National Security Threat">residential proxies</a>. <a class="footnote-backref" href="#fnref:bot-filter" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:referrer">
<p>Without JavaScript, I cannot send the real referrer. I insert “NoJS”
instead. <a class="footnote-backref" href="#fnref:referrer" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:rss">
<p>I am missing the humans reading the RSS feed. I am not comfortable adding a
<a href="https://www.goatcounter.com/help/pixel" title="GoatCounter documentation: tracking pixel without JavaScript">tracking pixel</a>, and bots are likely to fetch it, compromising the
statistics. I’ll live without counting these readers. <a class="footnote-backref" href="#fnref:rss" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:css-test">
<p>In February 2025, I tested another CSS-only approach without the interaction
trick: each page loaded an empty image as a background. Over a month, it
recorded up to ten times fewer page views than the nginx logs, even after
removing user agents identifying as bots. Compared to this experiment,
GoatCounter counts about three times fewer views, 18 months later. As the
time ranges differ, this is not an apples-to-apples comparison, but it gives
a hint about how efficient bot filtering is. <a class="footnote-backref" href="#fnref:css-test" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
<li id="fn:module">
<p>A <a href="https://nixos.org/manual/nixos/stable/#sec-writing-modules" title="NixOS Manual: Writing NixOS Modules">NixOS module</a> is a function receiving the configuration of the whole
system as <code>config</code> and returning three attributes:</p>
<ul>
<li><code>imports</code> adds other modules to import,</li>
<li><code>options</code> declares user-configurable settings, each with a type and a default value, and</li>
<li><code>config</code> sets values for options declared by any module, like <code>containers</code> from NixOS.</li>
</ul>
<p>If a module does not need to declare options, you can return the <code>config</code> attribute
set directly. When a module does not require any argument, you can define it as an attribute
set instead. <a class="footnote-backref" href="#fnref:module" title="Jump back to footnote 6 in the text">↩</a></p>
</li>
<li id="fn:import">
<p>Most of the time, you don’t need an explicit import. NixOS <a href="https://github.com/NixOS/nixpkgs/blob/master/nixos/modules/module-list.nix" title="nixpkgs/nixos/modules/module-list.nix">automatically
imports</a> the modules shipped with Nixpkgs. For my own
modules, <a href="https://github.com/vincentbernat/nixops-take1/blob/master/modules/default.nix" title="nixops-take1/modules/default.nix">some machinery</a> also imports them automatically. <a class="footnote-backref" href="#fnref:import" title="Jump back to footnote 7 in the text">↩</a></p>
</li>
<li id="fn:auto-recover">
<p>Litestream <a href="https://litestream.io/reference/config/#auto-recover" title="Litestream: auto-recover option">warns against using the <code>auto-recover</code> option</a> as
it can cause data loss. But I don’t properly monitor my servers and I prefer
uninterrupted backups to a slight chance of losing the last few records. <a class="footnote-backref" href="#fnref:auto-recover" title="Jump back to footnote 8 in the text">↩</a></p>
</li>
<li id="fn:slow">
<p>In my case, the process is slow: around 20 minutes for a 20 MiB database.
You can test by restoring to a copy with the <code>-o</code> option, but you still need
to stop the Litestream container. <a class="footnote-backref" href="#fnref:slow" title="Jump back to footnote 9 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">Offline PKI using 3 YubiKeys and an ARM single board computer</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/en/blog/2025-offline-pki-yubikeys" rel="alternate"/>
    <link href="https://vincent.bernat.ch/en/blog/2025-offline-pki-yubikeys#isso-thread" rel="replies" type="text/html"/>
    <updated>2025-03-17T07:12:54Z</updated>
    <id>http://www.luffy.cx/en/blog/2025-offline-pki-yubikeys.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>An offline <abbr title="Public Key Infrastructure">PKI</abbr> enhances security by physically isolating the certificate
authority from network threats. A <a href="https://www.yubico.com/products/yubikey-5-overview/" title="Discover YubiKey 5">YubiKey</a> is a low-cost solution to store a
root certificate. You also need an air-gapped environment to operate the root
<abbr title="Certificate Authority">CA</abbr>.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 400px"><span class="lf-media-inner" style="padding-bottom: 86.500%"><img alt="PKI relying on a set of 3 YubiKeys: 2 for the root CA and 1 for the&#10;intermediate CA." src="https://media-bernat-ch.b-cdn.net/images/3-yubikeys@1x.8beb69c4aaa2a2.jpg" srcset="https://media-bernat-ch.b-cdn.net/images/3-yubikeys@1x.8beb69c4aaa2a2.jpg 503w,https://media-bernat-ch.b-cdn.net/images/3-yubikeys@2x.4c6fa0e083e9cc.jpg 800w" sizes="auto, (max-width: 400px) 100vw, 400px" width="400" height="346" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAyAAAAK1AQMAAAAKYhhvAAAAA1BMVEW0o5wZvltTAAAAW0lEQVR42u3BMQEAAADCoPVPbQlPoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAmwEReAABFX7yMQAAAABJRU5ErkJggg==)"/></span></div><figcaption>Offline PKI backed up by 3 YubiKeys</figcaption></figure>
<p>This post describes an offline <abbr title="Public Key Infrastructure">PKI</abbr> system using the following components:</p>
<ul>
<li>2 YubiKeys for the <strong>root <abbr title="Certificate Authority">CA</abbr></strong> (with a 20-year validity),</li>
<li>1 YubiKey for the <strong>intermediate <abbr title="Certificate Authority">CA</abbr></strong> (with a 5-year validity), and</li>
<li>1 <a href="https://libre.computer/products/aml-s905x-cc-v2/" title="Sweet Potato AML-S905X-CC-V2">Libre Computer Sweet Potato</a> as an <strong>air-gapped <abbr title="Single Board Computer">SBC</abbr></strong>.</li>
</ul>
<p>It is possible to add more YubiKeys as a backup of the root <abbr title="Certificate Authority">CA</abbr> if needed. This
is not needed for the intermediate <abbr title="Certificate Authority">CA</abbr> as you can generate a new one if
the current one gets destroyed.</p>
<div class="toc">
<ul>
<li><a href="#the-software-part">The software part</a></li>
<li><a href="#the-hardware-part">The hardware part</a></li>
<li><a href="#the-nix-glue">The Nix glue</a></li>
</ul>
</div>
<h1 id="the-software-part">The software part</h1>
<p><a href="https://github.com/vincentbernat/offline-pki" title="Offline PKI system"><code>offline-pki</code></a> is a small Python application to manage an offline <abbr title="Public Key Infrastructure">PKI</abbr>.
It relies on <a href="https://github.com/Yubico/yubikey-manager" title="Python library for configuring any YubiKey">yubikey-manager</a> to manage YubiKeys and <a href="https://cryptography.io/en/latest/" title="cryptography's documentation">cryptography</a> for
cryptographic operations not executed on the YubiKeys. The application has some
opinionated design choices. Notably, the cryptography is hard-coded to use <a href="https://en.wikipedia.org/wiki/P-384" title="P-384 on Wikipedia">NIST
P-384 elliptic curve</a>.</p>
<p>The first step is to reset all your YubiKeys:</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>offline-pki<span class="w"> </span>yubikey<span class="w"> </span>reset
<span class="go">This will reset the connected YubiKey. Are you sure? [y/N]: y</span>
<span class="go">New PIN code:</span>
<span class="go">Repeat for confirmation:</span>
<span class="go">New PUK code:</span>
<span class="go">Repeat for confirmation:</span>
<span class="go">New management key ('.' to generate a random one):</span>
<span class="go">WARNING[pki-yubikey] Using random management key: e8ffdce07a4e3bd5c0d803aa3948a9c36cfb86ed5a2d5cf533e97b088ae9e629</span>
<span class="go">INFO[pki-yubikey]  0: Yubico YubiKey OTP+FIDO+CCID 00 00</span>
<span class="go">INFO[pki-yubikey] SN: 23854514</span>
<span class="go">INFO[yubikit.management] Device config written</span>
<span class="go">INFO[yubikit.piv] PIV application data reset performed</span>
<span class="go">INFO[yubikit.piv] Management key set</span>
<span class="go">INFO[yubikit.piv] New PUK set</span>
<span class="go">INFO[yubikit.piv] New PIN set</span>
<span class="go">INFO[pki-yubikey] YubiKey reset successful!</span>
</code></pre></div>

<p>Then, generate the root <abbr title="Certificate Authority">CA</abbr> and create as many copies as you want:</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>offline-pki<span class="w"> </span>certificate<span class="w"> </span>root<span class="w"> </span>--permitted<span class="w"> </span>example.com
<span class="go">Management key for Root X:</span>
<span class="go">Plug YubiKey "Root X"...</span>
<span class="go">INFO[pki-yubikey]  0: Yubico YubiKey CCID 00 00</span>
<span class="go">INFO[pki-yubikey] SN: 23854514</span>
<span class="go">INFO[yubikit.piv] Data written to object slot 0x5fc10a</span>
<span class="go">INFO[yubikit.piv] Certificate written to slot 9C (SIGNATURE), compression=True</span>
<span class="go">INFO[yubikit.piv] Private key imported in slot 9C (SIGNATURE) of type ECCP384</span>
<span class="go">Copy root certificate to another YubiKey? [y/N]: y</span>
<span class="go">Plug YubiKey "Root X"...</span>
<span class="go">INFO[pki-yubikey]  0: Yubico YubiKey CCID 00 00</span>
<span class="go">INFO[pki-yubikey] SN: 23854514</span>
<span class="go">INFO[yubikit.piv] Data written to object slot 0x5fc10a</span>
<span class="go">INFO[yubikit.piv] Certificate written to slot 9C (SIGNATURE), compression=True</span>
<span class="go">INFO[yubikit.piv] Private key imported in slot 9C (SIGNATURE) of type ECCP384</span>
<span class="go">Copy root certificate to another YubiKey? [y/N]: n</span>
</code></pre></div>

<p>You can inspect the result:</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>offline-pki<span class="w"> </span>yubikey<span class="w"> </span>info
<span class="go">INFO[pki-yubikey]  0: Yubico YubiKey CCID 00 00</span>
<span class="go">INFO[pki-yubikey] SN: 23854514</span>
<span class="go">INFO[pki-yubikey] Slot 9C (SIGNATURE):</span>
<span class="go">INFO[pki-yubikey]   Private key type: ECCP384</span>
<span class="go">INFO[pki-yubikey]   Public key:</span>
<span class="go">INFO[pki-yubikey]     Algorithm:  secp384r1</span>
<span class="go">INFO[pki-yubikey]     Issuer:     CN=Root CA</span>
<span class="go">INFO[pki-yubikey]     Subject:    CN=Root CA</span>
<span class="go">INFO[pki-yubikey]     Serial:     1</span>
<span class="go">INFO[pki-yubikey]     Not before: 2024-07-05T18:17:19+00:00</span>
<span class="go">INFO[pki-yubikey]     Not after:  2044-06-30T18:17:19+00:00</span>
<span class="go">INFO[pki-yubikey]     PEM:</span>
<span class="go">-----BEGIN CERTIFICATE-----</span>
<span class="go">MIIBcjCB+aADAgECAgEBMAoGCCqGSM49BAMDMBIxEDAOBgNVBAMMB1Jvb3QgQ0Ew</span>
<span class="go">HhcNMjQwNzA1MTgxNzE5WhcNNDQwNjMwMTgxNzE5WjASMRAwDgYDVQQDDAdSb290</span>
<span class="go">IENBMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAERg3Vir6cpEtB8Vgo5cAyBTkku/4w</span>
<span class="go">kXvhWlYZysz7+YzTcxIInZV6mpw61o8W+XbxZV6H6+3YHsr/IeigkK04/HJPi6+i</span>
<span class="go">zU5WJHeBJMqjj2No54Nsx6ep4OtNBMa/7T9foyMwITAPBgNVHRMBAf8EBTADAQH/</span>
<span class="go">MA4GA1UdDwEB/wQEAwIBhjAKBggqhkjOPQQDAwNoADBlAjEAwYKy/L8leJyiZSnn</span>
<span class="go">xrY8xv8wkB9HL2TEAI6fC7gNc2bsISKFwMkyAwg+mKFKN2w7AjBRCtZKg4DZ2iUo</span>
<span class="go">6c0BTXC9a3/28V5aydZj6rvx0JqbF/Ln5+RQL6wFMLoPIvCIiCU=</span>
<span class="go">-----END CERTIFICATE-----</span>
</code></pre></div>

<p>Then, you can create an intermediate certificate with <code>offline-pki yubikey
intermediate</code> and use it to sign certificates by providing a <abbr title="Certificate Signing Request">CSR</abbr> to <code>offline-pki
certificate sign</code>. Be careful and inspect the <abbr title="Certificate Signing Request">CSR</abbr> before signing it, as only the
subject name can be overridden. Check the <a href="https://github.com/vincentbernat/offline-pki?tab=readme-ov-file#operations">documentation</a> for more details.
Get the available options using the <code>--help</code> flag.</p>
<h1 id="the-hardware-part">The hardware part</h1>
<p>To ensure the operations on the root and intermediate <abbr title="Certificate Authorities">CAs</abbr> are air-gapped,
a cost-efficient solution is to use an ARM64 single board computer. The <a href="https://libre.computer/products/aml-s905x-cc-v2/" title="Sweet Potato AML-S905X-CC-V2">Libre
Computer Sweet Potato</a> <abbr title="Single Board Computer">SBC</abbr> is a more open alternative to the well-known
Raspberry Pi.<sup id="fnref:root"><a class="footnote-ref" href="#fn:root">1</a></sup></p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 800px"><span class="lf-media-inner" style="padding-bottom: 70.000%"><img alt="Libre Computer Sweet Potato single board computer relying on the Amlogic S905X&#10;SOC" src="https://media-bernat-ch.b-cdn.net/images/sweet-potato@1x.34ce59fcbcf47f.jpg" srcset="https://media-bernat-ch.b-cdn.net/images/sweet-potato@1x.34ce59fcbcf47f.jpg 1006w,https://media-bernat-ch.b-cdn.net/images/sweet-potato@2x.e575728a8cf197.jpg 1600w" sizes="auto, (max-width: 800px) 100vw, 800px" width="800" height="560" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABkAAAARhAQMAAABArdteAAAAA1BMVEXGnW8rul+2AAAA8UlEQVR42u3BMQEAAADCoPVPbQo/oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAuBhwVgABAgn2ogAAAABJRU5ErkJggg==)"/></span></div><figcaption>Libre Computer Sweet Potato SBC, powered by the AML-S905X SOC</figcaption></figure>
<p>I interact with it through an <a href="https://www.amazon.com/dp/B07WX2DSVB" title="USB to TTL UART converter on Amazon">USB to TTL UART converter</a>:</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>tio<span class="w"> </span>/dev/ttyUSB0
<span class="go">[16:40:44.546] tio v3.7</span>
<span class="go">[16:40:44.546] Press ctrl-t q to quit</span>
<span class="go">[16:40:44.555] Connected to /dev/ttyUSB0</span>
<span class="go">GXL:BL1:9ac50e:bb16dc;FEAT:ADFC318C:0;POC:1;RCY:0;SPI:0;0.0;CHK:0;</span>
<span class="go">TE: 36574</span>

<span class="go">BL2 Built : 15:21:18, Aug 28 2019. gxl g1bf2b53 - luan.yuan@droid15-sz</span>

<span class="go">set vcck to 1120 mv</span>
<span class="go">set vddee to 1000 mv</span>
<span class="go">Board ID = 4</span>
<span class="go">CPU clk: 1200MHz</span>
<span class="go">[…]</span>
</code></pre></div>

<h1 id="the-nix-glue">The Nix glue</h1>
<p>To bring everything together, I am using <em>Nix</em> with a <a href="https://github.com/vincentbernat/offline-pki/blob/main/flake.nix">Flake</a> providing:</p>
<ul>
<li>a package for the <code>offline-pki</code> application, with shell completion,</li>
<li>a development shell, including an editable version of the <code>offline-pki</code> application,</li>
<li>a NixOS module to set up the offline <abbr title="Public Key Infrastructure">PKI</abbr>, resetting the system at each boot,</li>
<li>a QEMU image for testing, and</li>
<li>an SD card image to be used on the <em>Sweet Potato</em> or another ARM64 <abbr title="Single Board Computer">SBC</abbr>.</li>
</ul>
<div class="language-bash codehilite"><pre><span/><code><span class="c1"># Execute the application locally</span>
nix<span class="w"> </span>run<span class="w"> </span>github:vincentbernat/offline-pki<span class="w"> </span>--<span class="w"> </span>--help
<span class="c1"># Run the application inside a QEMU VM</span>
nix<span class="w"> </span>run<span class="w"> </span>github:vincentbernat/offline-pki<span class="se">\#</span>qemu
<span class="c1"># Build an SD card for the Sweet Potato or for the Raspberry Pi</span>
nix<span class="w"> </span>build<span class="w"> </span>--system<span class="w"> </span>aarch64-linux<span class="w"> </span>github:vincentbernat/offline-pki<span class="se">\#</span>sdcard.potato
nix<span class="w"> </span>build<span class="w"> </span>--system<span class="w"> </span>aarch64-linux<span class="w"> </span>github:vincentbernat/offline-pki<span class="se">\#</span>sdcard.generic
<span class="c1"># Get a development shell with the application</span>
nix<span class="w"> </span>develop<span class="w"> </span>github:vincentbernat/offline-pki
</code></pre></div>

<div class="footnote">
<hr/>
<ol>
<li id="fn:root">
<p>The key for the root <abbr title="Certificate Authority">CA</abbr> is not generated by the YubiKey. Using an
air-gapped computer is all the more important. Put it in a safe with the
YubiKeys when done! <a class="footnote-backref" href="#fnref:root" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">Customize Caddy's plugins with Nix</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/en/blog/2024-caddy-nix-plugins" rel="alternate"/>
    <link href="https://vincent.bernat.ch/en/blog/2024-caddy-nix-plugins#isso-thread" rel="replies" type="text/html"/>
    <updated>2024-11-11T06:35:50Z</updated>
    <id>http://www.luffy.cx/en/blog/2024-caddy-nix-plugins.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p><a href="https://caddyserver.com/" title="Caddy Web Server">Caddy</a> is an open-source web server written in Go. It handles TLS
certificates automatically and comes with a simple configuration syntax. Users
can extend its functionality through <a href="https://caddyserver.com/docs/modules/" title="All registered Caddy plugins">plugins</a><sup id="fnref:plugins"><a class="footnote-ref" href="#fn:plugins">1</a></sup> to add features like
<a href="https://github.com/mholt/caddy-ratelimit" title="HTTP rate limiting module for Caddy">rate limiting</a>, <a href="https://github.com/caddyserver/cache-handler" title="Distributed HTTP caching module for Caddy">caching</a>, and <a href="https://github.com/invzhi/caddy-docker-upstreams" title="Docker dynamic upstreams for Caddy">Docker integration</a>.</p>
<p>While <em>Caddy</em> is available in <a href="https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/ca/caddy/package.nix">Nixpkgs</a>, adding extra plugins <del>is</del>
<ins>was</ins> not simple.<sup id="fnref:simple"><a class="footnote-ref" href="#fn:simple">2</a></sup> The compilation process needs Internet
access, which <a href="https://zero-to-nix.com/concepts/hermeticity/" title="Zero to Nix: hermeticity">Nix denies during build</a> to ensure <a href="https://zero-to-nix.com/concepts/reproducibility/" title="Zero to Nix: reproducibility">reproducibility</a>. When
trying to build the following derivation using <a href="https://github.com/caddyserver/xcaddy" title="Build Caddy with plugins">xcaddy</a>, a tool for building
Caddy with plugins, it fails with this error: <code>dial tcp: lookup proxy.golang.org
on [::1]:53: connection refused</code>.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> pkgs <span class="p">}:</span>
pkgs<span class="o">.</span>stdenv<span class="o">.</span>mkDerivation <span class="p">{</span>
  <span class="ss">name</span> <span class="o">=</span> <span class="s2">"caddy-with-xcaddy"</span><span class="p">;</span>
  <span class="ss">nativeBuildInputs</span> <span class="o">=</span> <span class="k">with</span> pkgs<span class="p">;</span> <span class="p">[</span> go xcaddy cacert <span class="p">];</span>
  <span class="ss">unpackPhase</span> <span class="o">=</span> <span class="s2">"true"</span><span class="p">;</span>
  <span class="ss">buildPhase</span> <span class="o">=</span>
    <span class="s s-Multiline">''</span>
<span class="s s-Multiline">      xcaddy build --with github.com/caddy-dns/powerdns@v1.0.1</span>
<span class="s s-Multiline">    ''</span><span class="p">;</span>
  <span class="ss">installPhase</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">    mkdir -p $out/bin</span>
<span class="s s-Multiline">    cp caddy $out/bin</span>
<span class="s s-Multiline">  ''</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>Fixed-output derivations are an exception to this rule and get network access
during build. They need to specify their output hash. For example, the
<code>fetchurl</code> function produces a fixed-output derivation:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span> stdenv<span class="p">,</span> fetchurl <span class="p">}:</span>
stdenv<span class="o">.</span>mkDerivation <span class="k">rec</span> <span class="p">{</span>
  <span class="ss">pname</span> <span class="o">=</span> <span class="s2">"hello"</span><span class="p">;</span>
  <span class="ss">version</span> <span class="o">=</span> <span class="s2">"2.12.1"</span><span class="p">;</span>
  <span class="ss">src</span> <span class="o">=</span> fetchurl <span class="p">{</span>
    <span class="ss">url</span> <span class="o">=</span> <span class="s2">"mirror://gnu/hello/hello-</span><span class="si">${</span>version<span class="si">}</span><span class="s2">.tar.gz"</span><span class="p">;</span>
    <span class="ss">hash</span> <span class="o">=</span> <span class="s2">"sha256-jZkUKv2SV28wsM18tCqNxoCZmLxdYH2Idh9RLibH2yA="</span><span class="p">;</span>
  <span class="p">};</span>
<span class="p">}</span>
</code></pre></div>

<p>To create a fixed-output derivation, you need to set the <a href="https://nix.dev/manual/nix/2.22/language/advanced-attributes.html#adv-attr-outputHash"><code>outputHash</code>
attribute</a>. The example below shows how to output <em>Caddy</em>’s source
code, with some plugin enabled, as a fixed-output derivation using <code>xcaddy</code> and
<code>go mod vendor</code>.</p>
<div class="language-nix codehilite"><pre><span/><code>pkgs<span class="o">.</span>stdenvNoCC<span class="o">.</span>mkDerivation <span class="k">rec</span> <span class="p">{</span>
  <span class="ss">pname</span> <span class="o">=</span> <span class="s2">"caddy-src-with-xcaddy"</span><span class="p">;</span>
  <span class="ss">version</span> <span class="o">=</span> <span class="s2">"2.8.4"</span><span class="p">;</span>
  <span class="ss">nativeBuildInputs</span> <span class="o">=</span> <span class="k">with</span> pkgs<span class="p">;</span> <span class="p">[</span> go xcaddy cacert <span class="p">];</span>
  <span class="ss">unpackPhase</span> <span class="o">=</span> <span class="s2">"true"</span><span class="p">;</span>
  <span class="ss">buildPhase</span> <span class="o">=</span>
    <span class="s s-Multiline">''</span>
<span class="s s-Multiline">      export GOCACHE=$TMPDIR/go-cache</span>
<span class="s s-Multiline">      export GOPATH="$TMPDIR/go"</span>
<span class="s s-Multiline">      XCADDY_SKIP_BUILD=1 TMPDIR="$PWD" \</span>
<span class="s s-Multiline">        xcaddy build v</span><span class="si">${</span>version<span class="si">}</span><span class="s s-Multiline"> --with github.com/caddy-dns/powerdns@v1.0.1</span>
<span class="s s-Multiline">      (cd buildenv* &amp;&amp; go mod vendor)</span>
<span class="s s-Multiline">    ''</span><span class="p">;</span>
  <span class="ss">installPhase</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">    mv buildenv* $out</span>
<span class="s s-Multiline">  ''</span><span class="p">;</span>

<span class="hll">  <span class="ss">outputHash</span> <span class="o">=</span> <span class="s2">"sha256-F/jqR4iEsklJFycTjSaW8B/V3iTGqqGOzwYBUXxRKrc="</span><span class="p">;</span>
</span><span class="hll">  <span class="ss">outputHashAlgo</span> <span class="o">=</span> <span class="s2">"sha256"</span><span class="p">;</span>
</span><span class="hll">  <span class="ss">outputHashMode</span> <span class="o">=</span> <span class="s2">"recursive"</span><span class="p">;</span>
</span><span class="p">}</span>
</code></pre></div>

<p>With a fixed-output derivation, it is up to us to ensure the output is always
the same:</p>
<ul>
<li>we ask <code>xcaddy</code> to not compile the program and keep the source code,<sup id="fnref:xcaddy"><a class="footnote-ref" href="#fn:xcaddy">3</a></sup></li>
<li>we pin the version of Caddy we want to build, and</li>
<li>we pin the version of each requested plugin.</li>
</ul>
<p>You can use this derivation to override the <code>src</code> attribute in <code>pkgs.caddy</code>:</p>
<div class="language-nix codehilite"><pre><span/><code>pkgs<span class="o">.</span>caddy<span class="o">.</span>overrideAttrs <span class="p">(</span>prev<span class="p">:</span> <span class="p">{</span>
  <span class="ss">src</span> <span class="o">=</span> pkgs<span class="o">.</span>stdenvNoCC<span class="o">.</span>mkDerivation <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">};</span>
  <span class="ss">vendorHash</span> <span class="o">=</span> <span class="no">null</span><span class="p">;</span>
  <span class="ss">subPackages</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"."</span> <span class="p">];</span>
<span class="p">});</span>
</code></pre></div>

<p>Check out the complete example in the <a href="https://github.com/vincentbernat/caddy-nix/blob/main/examples/xcaddy-src/flake.nix">Git repository</a>. To integrate into a
<a href="https://zero-to-nix.com/concepts/flakes/" title="Zero to Nix: flakes">Flake</a>, add <code>github:vincentbernat/caddy-nix</code> as an overlay:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span>
  <span class="ss">inputs</span> <span class="o">=</span> <span class="p">{</span>
    nixpkgs<span class="o">.</span><span class="ss">url</span> <span class="o">=</span> <span class="s2">"nixpkgs"</span><span class="p">;</span>
    flake-utils<span class="o">.</span><span class="ss">url</span> <span class="o">=</span> <span class="s2">"github:numtide/flake-utils"</span><span class="p">;</span>
    caddy<span class="o">.</span><span class="ss">url</span> <span class="o">=</span> <span class="s2">"github:vincentbernat/caddy-nix"</span><span class="p">;</span>
  <span class="p">};</span>
  <span class="ss">outputs</span> <span class="o">=</span> <span class="p">{</span> self<span class="p">,</span> nixpkgs<span class="p">,</span> flake-utils<span class="p">,</span> caddy <span class="p">}:</span>
    flake-utils<span class="o">.</span>lib<span class="o">.</span>eachDefaultSystem <span class="p">(</span>system<span class="p">:</span>
      <span class="k">let</span>
        <span class="ss">pkgs</span> <span class="o">=</span> <span class="nb">import</span> nixpkgs <span class="p">{</span>
          <span class="k">inherit</span> system<span class="p">;</span>
          <span class="ss">overlays</span> <span class="o">=</span> <span class="p">[</span> caddy<span class="o">.</span>overlays<span class="o">.</span>default <span class="p">];</span>
        <span class="p">};</span>
      <span class="k">in</span>
      <span class="p">{</span>
        <span class="ss">packages</span> <span class="o">=</span> <span class="p">{</span>
          <span class="ss">default</span> <span class="o">=</span> pkgs<span class="o">.</span>caddy<span class="o">.</span>withPlugins <span class="p">{</span>
            <span class="ss">plugins</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"github.com/caddy-dns/powerdns@v1.0.1"</span> <span class="p">];</span>
            <span class="ss">hash</span> <span class="o">=</span> <span class="s2">"sha256-F/jqR4iEsklJFycTjSaW8B/V3iTGqqGOzwYBUXxRKrc="</span><span class="p">;</span>
          <span class="p">};</span>
        <span class="p">};</span>
      <span class="p">});</span>
<span class="p">}</span>
</code></pre></div>

<div class="admonition">
<p class="admonition-title">Update (2024-11)</p>
<p>This flake won’t work with Nixpkgs 24.05 or older because it relies on <a href="https://github.com/nixOS/nixpkgs/commit/eed069a5bc40ba4d871de7700c7eb8d592e98cb6">this
commit</a>
to properly override the <code>vendorHash</code> attribute.</p>
</div>
<div class="admonition">
<p class="admonition-title">Update (2025-05)</p>
<p>My <a href="https://github.com/NixOS/nixpkgs/pull/358586" title="caddy: add suport for compiling Caddy with plugins #358586">pull request</a> for Nixpkgs was merged and since Nixpkgs 25.05, you
don’t need to specify the overlay anymore. Check the <a href="https://github.com/vincentbernat/caddy-nix/blob/main/examples/nixpkgs-25.05/flake.nix">complete
example</a>.</p>
</div>
<div class="footnote">
<hr/>
<ol>
<li id="fn:plugins">
<p>This article uses the term “plugins,” though Caddy documentation
also refers to them as “modules” since they are implemented as Go modules. <a class="footnote-backref" href="#fnref:plugins" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:simple">
<p>This has been a <a href="https://github.com/NixOS/nixpkgs/issues/14671" title="caddy: add all addons #14671">feature request</a> for quite some time. A <a href="https://github.com/NixOS/nixpkgs/pull/317881" title="caddy: add support for compiling with Caddy modules (plugins) #317881">proposed
solution</a> has been rejected. The one described in this article is a bit
different and I have proposed it in another <a href="https://github.com/NixOS/nixpkgs/pull/358586" title="caddy: add suport for compiling Caddy with plugins #358586">pull request</a>, which was merged. <a class="footnote-backref" href="#fnref:simple" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:xcaddy">
<p>This is not perfect: if the source code produced by <code>xcaddy</code> changes,
the hash would change and the build would fail. <a class="footnote-backref" href="#fnref:xcaddy" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">Managing infrastructure with Terraform, CDKTF, and NixOS</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/en/blog/2022-cdktf-nixos" rel="alternate"/>
    <link href="https://vincent.bernat.ch/en/blog/2022-cdktf-nixos#isso-thread" rel="replies" type="text/html"/>
    <updated>2022-12-26T14:18:38Z</updated>
    <id>http://www.luffy.cx/en/blog/2022-cdktf-nixos.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>A few years ago, I downsized my personal infrastructure. Until 2018, there were
a dozen containers running on a single <a href="https://www.hetzner.com/" title="Hetzner: dedicated servers, cloud, storage &amp; hosting">Hetzner</a> server.<sup id="fnref:hetzner"><a class="footnote-ref" href="#fn:hetzner">1</a></sup> I migrated
my emails to <a href="https://www.fastmail.com/">Fastmail</a> and my DNS zones to <a href="https://www.gandi.net/en" title="Gandi: Domain Names, Web Hosting, Mail">Gandi</a>. It left me with only my
blog to self-host. As of today, my low-scale infrastructure is composed of 4
virtual machines running <a href="https://nixos.org/" title="Nix: reproducible builds and deployments">NixOS</a> on <em>Hetzner Cloud</em> and <a href="https://www.vultr.com/" title="Vultr.com: SSD VPS Servers, Cloud Servers and Cloud Hosting">Vultr</a>, a handful
of DNS zones on <em>Gandi</em> and <a href="https://aws.amazon.com/route53/" title="Amazon AWS: Route 53">Route 53</a>, and a couple of <a href="https://aws.amazon.com/cloudfront/" title="Amazon AWS: CloudFront">Cloudfront</a>
distributions. It is managed by <a href="https://developer.hashicorp.com/terraform/cdktf" title="Cloud Development Kit for Terraform">CDK for Terraform</a> (<abbr title="Cloud Development Kit for Terraform">CDKTF</abbr>), while <em>NixOS</em>
deployments are handled by <a href="https://github.com/NixOS/nixops" title="NixOps:  tool for deploying to NixOS machines in a network or cloud">NixOps</a>.</p>
<p>In this article, I provide a brief introduction to <em>Terraform</em>, <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em>, and the
<em>Nix</em> ecosystem. I also explain how to use <em>Nix</em> to access these tools within
your shell, so you can quickly start using them.</p>
<div class="admonition">
<p class="admonition-title">Update (2025-10)</p>
<p><em>HashiCorp</em> <a href="https://www.hashicorp.com/blog/hashicorp-adopts-business-source-license">switched to the Business Source License</a> for all its
software. It is quite a disappointment, especially for <em>Terraform</em>, a key
component in automation as it has benefited from a large community. There is
a community-driven fork, <a href="https://opentofu.org/" title="The open source infrastructure as code tool">OpenTofu</a>. However, it does not cover <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em>
and the project <a href="https://github.com/opentofu/opentofu/issues/1335#issuecomment-3365674369">stated</a> it won’t provide compatibility.</p>
</div>
<div class="admonition">
<p class="admonition-title">Update (2025-12)</p>
<p><a href="https://github.com/hashicorp/terraform-cdk/commit/a0c56138e2b4c7b6c128bd7ba08a8e317df852af"><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr> is deprecated</a>. This is another disappointment. I suppose I need to
have a second look at <a href="https://www.pulumi.com/" title="Pulumi: universal infrastructure as code">Pulumi</a>.</p>
</div>
<div class="admonition">
<p class="admonition-title">Update (2026-10)</p>
<p>In the end, I switched to <a href="https://opentofu.org/" title="The open source infrastructure as code tool">OpenTofu</a> and <a href="https://cdktn.io/" title="CDK Terrain: infrastructure as code with programming languages">CDK Terrain</a>, a
community-driven fork of <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em>.</p>
</div>
<div class="toc">
<ul>
<li><a href="#cdktf-infrastructure-as-code">CDKTF: infrastructure as code</a><ul>
<li><a href="#managing-servers">Managing servers</a></li>
<li><a href="#managing-dns-records">Managing DNS records</a></li>
<li><a href="#about-pulumi">About Pulumi</a></li>
</ul>
</li>
<li><a href="#nixos-nixops">NixOS &amp; NixOps</a><ul>
<li><a href="#nixos-declarative-linux-distribution">NixOS: declarative Linux distribution</a></li>
<li><a href="#nixops-nixos-deployment-tool">NixOps: NixOS deployment tool</a></li>
</ul>
</li>
<li><a href="#tying-everything-together-with-nix">Tying everything together with Nix</a><ul>
<li><a href="#brief-introduction-to-nix-flakes">Brief introduction to Nix flakes</a></li>
<li><a href="#nix-and-cdktf">Nix and CDKTF</a></li>
<li><a href="#nixops">NixOps</a></li>
</ul>
</li>
</ul>
</div>
<h1 id="cdktf-infrastructure-as-code"><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr>: infrastructure as code</h1>
<p><a href="https://developer.hashicorp.com/terraform">Terraform</a> is an “infrastructure-as-code” tool. You can define your
infrastructure by declaring resources with the <a href="https://developer.hashicorp.com/terraform/language/modules"><abbr title="HashiCorp Configuration Language">HCL</abbr> language</a>. This language
has some additional features like loops to declare several resources from a
list, built-in functions you can call in expressions, and string templates.
<em>Terraform</em> relies on a <a href="https://registry.terraform.io/browse/providers">large set of providers</a> to manage resources.</p>
<h2 id="managing-servers">Managing servers</h2>
<p>Here is a short example using the <a href="https://registry.terraform.io/providers/hetznercloud/hcloud/latest/docs">Hetzner Cloud provider</a> to spawn a virtual
machine:</p>
<div class="language-terraform codehilite"><pre><span/><code><span class="kr">variable</span><span class="w"> </span><span class="nv">"hcloud_token"</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="na">sensitive</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="no">true</span>
<span class="p">}</span>
<span class="kr">provider</span><span class="w"> </span><span class="nv">"hcloud"</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="na">token</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">var.hcloud_token</span>
<span class="p">}</span>

<span class="kr">resource</span><span class="w"> </span><span class="nc">"hcloud_server"</span><span class="w"> </span><span class="nv">"web03"</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="na">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"web03"</span>
<span class="w">  </span><span class="na">server_type</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"cpx11"</span>
<span class="w">  </span><span class="na">image</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"debian-11"</span>
<span class="w">  </span><span class="na">datacenter</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"nbg1-dc3"</span>
<span class="p">}</span>

<span class="kr">resource</span><span class="w"> </span><span class="nc">"hcloud_rdns"</span><span class="w"> </span><span class="nv">"rdns4-web03"</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="na">server_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">hcloud_server.web03.id</span>
<span class="w">  </span><span class="na">ip_address</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">hcloud_server.web03.ipv4_address</span>
<span class="w">  </span><span class="na">dns_ptr</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"web03.luffy.cx"</span>
<span class="p">}</span>

<span class="kr">resource</span><span class="w"> </span><span class="nc">"hcloud_rdns"</span><span class="w"> </span><span class="nv">"rdns6-web03"</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="na">server_id</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">hcloud_server.web03.id</span>
<span class="w">  </span><span class="na">ip_address</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nv">hcloud_server.web03.ipv6_address</span>
<span class="w">  </span><span class="na">dns_ptr</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s2">"web03.luffy.cx"</span>
<span class="p">}</span>
</code></pre></div>

<p><em><abbr title="HashiCorp Configuration Language">HCL</abbr></em> expressiveness is quite limited and I find a general-purpose language more
convenient to describe all the resources. This is where <em>CDK for Terraform</em>
comes in: you can manage your infrastructure using your preferred programming
language, including TypeScript, Go, and Python. Here is the previous example
using <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em> and TypeScript:</p>
<div class="language-typescript codehilite"><pre><span/><code><span class="k">import</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">App</span><span class="p">,</span><span class="w"> </span><span class="nx">TerraformStack</span><span class="p">,</span><span class="w"> </span><span class="nx">Fn</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">from</span><span class="w"> </span><span class="s2">"cdktf"</span><span class="p">;</span>
<span class="k">import</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="nx">HcloudProvider</span><span class="w"> </span><span class="p">}</span><span class="w"> </span><span class="kr">from</span><span class="w"> </span><span class="s2">"./.gen/providers/hcloud/provider"</span><span class="p">;</span>
<span class="k">import</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="kr">as</span><span class="w"> </span><span class="nx">hcloud</span><span class="w"> </span><span class="kr">from</span><span class="w"> </span><span class="s2">"./.gen/providers/hcloud"</span><span class="p">;</span>

<span class="kd">class</span><span class="w"> </span><span class="nx">MyStack</span><span class="w"> </span><span class="k">extends</span><span class="w"> </span><span class="nx">TerraformStack</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kr">constructor</span><span class="p">(</span><span class="nx">scope</span><span class="o">:</span><span class="w"> </span><span class="kt">Construct</span><span class="p">,</span><span class="w"> </span><span class="nx">name</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">super</span><span class="p">(</span><span class="nx">scope</span><span class="p">,</span><span class="w"> </span><span class="nx">name</span><span class="p">);</span>

<span class="w">    </span><span class="kd">const</span><span class="w"> </span><span class="nx">hcloudToken</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">TerraformVariable</span><span class="p">(</span><span class="k">this</span><span class="p">,</span><span class="w"> </span><span class="s2">"hcloudToken"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="kr">type</span><span class="o">:</span><span class="w"> </span><span class="s2">"string"</span><span class="p">,</span>
<span class="w">      </span><span class="nx">sensitive</span><span class="o">:</span><span class="w"> </span><span class="kt">true</span><span class="p">,</span>
<span class="w">    </span><span class="p">});</span>
<span class="w">    </span><span class="kd">const</span><span class="w"> </span><span class="nx">hcloudProvider</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">HcloudProvider</span><span class="p">(</span><span class="k">this</span><span class="p">,</span><span class="w"> </span><span class="s2">"hcloud"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="nx">token</span><span class="o">:</span><span class="w"> </span><span class="kt">hcloudToken.value</span><span class="p">,</span>
<span class="w">    </span><span class="p">});</span>

<span class="w">    </span><span class="kd">const</span><span class="w"> </span><span class="nx">web03</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">hcloud</span><span class="p">.</span><span class="nx">server</span><span class="p">.</span><span class="nx">Server</span><span class="p">(</span><span class="k">this</span><span class="p">,</span><span class="w"> </span><span class="s2">"web03"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="nx">name</span><span class="o">:</span><span class="w"> </span><span class="s2">"web03"</span><span class="p">,</span>
<span class="w">      </span><span class="nx">serverType</span><span class="o">:</span><span class="w"> </span><span class="s2">"cpx11"</span><span class="p">,</span>
<span class="w">      </span><span class="nx">image</span><span class="o">:</span><span class="w"> </span><span class="s2">"debian-11"</span><span class="p">,</span>
<span class="w">      </span><span class="nx">datacenter</span><span class="o">:</span><span class="w"> </span><span class="s2">"nbg1-dc3"</span><span class="p">,</span>
<span class="w">      </span><span class="nx">provider</span><span class="o">:</span><span class="w"> </span><span class="kt">hcloudProvider</span><span class="p">,</span>
<span class="w">    </span><span class="p">});</span>
<span class="w">    </span><span class="ow">new</span><span class="w"> </span><span class="nx">hcloud</span><span class="p">.</span><span class="nx">rdns</span><span class="p">.</span><span class="nx">Rdns</span><span class="p">(</span><span class="k">this</span><span class="p">,</span><span class="w"> </span><span class="s2">"rdns4-web03"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="nx">serverId</span><span class="o">:</span><span class="w"> </span><span class="kt">Fn.tonumber</span><span class="p">(</span><span class="nx">web03</span><span class="p">.</span><span class="nx">id</span><span class="p">),</span>
<span class="w">      </span><span class="nx">ipAddress</span><span class="o">:</span><span class="w"> </span><span class="kt">web03.ipv4Address</span><span class="p">,</span>
<span class="w">      </span><span class="nx">dnsPtr</span><span class="o">:</span><span class="w"> </span><span class="s2">"web03.luffy.cx"</span><span class="p">,</span>
<span class="w">      </span><span class="nx">provider</span><span class="o">:</span><span class="w"> </span><span class="kt">hcloudProvider</span><span class="p">,</span>
<span class="w">    </span><span class="p">});</span>
<span class="w">    </span><span class="ow">new</span><span class="w"> </span><span class="nx">hcloud</span><span class="p">.</span><span class="nx">rdns</span><span class="p">.</span><span class="nx">Rdns</span><span class="p">(</span><span class="k">this</span><span class="p">,</span><span class="w"> </span><span class="s2">"rdns6-web03"</span><span class="p">,</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="nx">serverId</span><span class="o">:</span><span class="w"> </span><span class="kt">Fn.tonumber</span><span class="p">(</span><span class="nx">web03</span><span class="p">.</span><span class="nx">id</span><span class="p">),</span>
<span class="w">      </span><span class="nx">ipAddress</span><span class="o">:</span><span class="w"> </span><span class="kt">web03.ipv6Address</span><span class="p">,</span>
<span class="w">      </span><span class="nx">dnsPtr</span><span class="o">:</span><span class="w"> </span><span class="s2">"web03.luffy.cx"</span><span class="p">,</span>
<span class="w">      </span><span class="nx">provider</span><span class="o">:</span><span class="w"> </span><span class="kt">hcloudProvider</span><span class="p">,</span>
<span class="w">    </span><span class="p">});</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>

<span class="kd">const</span><span class="w"> </span><span class="nx">app</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="ow">new</span><span class="w"> </span><span class="nx">App</span><span class="p">();</span>
<span class="ow">new</span><span class="w"> </span><span class="nx">MyStack</span><span class="p">(</span><span class="nx">app</span><span class="p">,</span><span class="w"> </span><span class="s2">"cdktf-take1"</span><span class="p">);</span>
<span class="nx">app</span><span class="p">.</span><span class="nx">synth</span><span class="p">();</span>
</code></pre></div>

<p>Running <code>cdktf synth</code> generates a configuration file for <em>Terraform</em>, <code>terraform
plan</code> previews the changes, and <code>terraform apply</code> applies them. Now that you
have a general-purpose language, you can use functions.</p>
<h2 id="managing-dns-records">Managing DNS records</h2>
<p>While using <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em> for 4 web servers may seem a tad overkill, this is quite
different when it comes to managing a few DNS zones. With <a href="https://dnscontrol.org/" title="DNSControl: Synchronize your DNS to multiple providers from a simple DSL">DNSControl</a>, which
is using JavaScript as a domain-specific language, I was able to define the
<code>bernat.ch</code> zone with this snippet of code:</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="nx">D</span><span class="p">(</span><span class="s2">"bernat.ch"</span><span class="p">,</span><span class="w"> </span><span class="nx">REG_NONE</span><span class="p">,</span><span class="w"> </span><span class="nx">DnsProvider</span><span class="p">(</span><span class="nx">DNS_BIND</span><span class="p">,</span><span class="w"> </span><span class="mf">0</span><span class="p">),</span><span class="w"> </span><span class="nx">DnsProvider</span><span class="p">(</span><span class="nx">DNS_GANDI</span><span class="p">),</span>
<span class="w">  </span><span class="nx">DefaultTTL</span><span class="p">(</span><span class="s1">'2h'</span><span class="p">),</span>
<span class="w">  </span><span class="nx">FastMailMX</span><span class="p">(</span><span class="s1">'bernat.ch'</span><span class="p">,</span><span class="w"> </span><span class="p">{</span><span class="nx">subdomains</span><span class="o">:</span><span class="w"> </span><span class="p">[</span><span class="s1">'vincent'</span><span class="p">]}),</span>
<span class="w">  </span><span class="nx">WebServers</span><span class="p">(</span><span class="s1">'@'</span><span class="p">),</span>
<span class="w">  </span><span class="nx">WebServers</span><span class="p">(</span><span class="s1">'vincent'</span><span class="p">);</span>
</code></pre></div>

<p>This generated 38 records. With <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em>, I use:</p>
<div class="language-typescript codehilite"><pre><span/><code><span class="ow">new</span><span class="w"> </span><span class="nx">Route53Zone</span><span class="p">(</span><span class="k">this</span><span class="p">,</span><span class="w"> </span><span class="s2">"bernat.ch"</span><span class="p">,</span><span class="w"> </span><span class="nx">providers</span><span class="p">.</span><span class="nx">aws</span><span class="p">)</span>
<span class="w">  </span><span class="p">.</span><span class="nx">sign</span><span class="p">(</span><span class="nx">dnsCMK</span><span class="p">)</span>
<span class="w">  </span><span class="p">.</span><span class="nx">registrar</span><span class="p">(</span><span class="nx">providers</span><span class="p">.</span><span class="nx">gandiVB</span><span class="p">)</span>
<span class="w">  </span><span class="p">.</span><span class="nx">www</span><span class="p">(</span><span class="s2">"@"</span><span class="p">,</span><span class="w"> </span><span class="nx">servers</span><span class="p">)</span>
<span class="w">  </span><span class="p">.</span><span class="nx">www</span><span class="p">(</span><span class="s2">"vincent"</span><span class="p">,</span><span class="w"> </span><span class="nx">servers</span><span class="p">)</span>
<span class="w">  </span><span class="p">.</span><span class="nx">www</span><span class="p">(</span><span class="s2">"media"</span><span class="p">,</span><span class="w"> </span><span class="nx">servers</span><span class="p">)</span>
<span class="w">  </span><span class="p">.</span><span class="nx">fastmailMX</span><span class="p">([</span><span class="s2">"vincent"</span><span class="p">]);</span>
</code></pre></div>

<p>All the magic is in the code that I did not show you. You can check the
<a href="https://github.com/vincentbernat/cdktf-take1/blob/51dfb3f4dbeb/luffy/dns.ts">dns.ts</a> file in the <a href="https://github.com/vincentbernat/cdktf-take1/tree/51dfb3f4dbeb">cdktf-take1</a> repository to see how it works. Here is a
quick explanation:</p>
<ul>
<li><code>Route53Zone()</code> creates a new zone hosted by <em>Route 53</em>,</li>
<li><code>sign()</code> signs the zone with the provided signing key,</li>
<li><code>registrar()</code> registers the zone to the registrar of the domain and sets up DNSSEC,</li>
<li><code>www()</code> creates <code>A</code> and <code>AAAA</code> records for the provided name pointing to the web servers,</li>
<li><code>fastmailMX()</code> creates the <code>MX</code> records and other support records to direct emails to <em>Fastmail</em>.</li>
</ul>
<p>Here is the content of the <code>fastmailMX()</code> function. It generates a few records
and returns the current zone for chaining:</p>
<div class="language-typescript codehilite"><pre><span/><code><span class="nx">fastmailMX</span><span class="p">(</span><span class="nx">subdomains?</span><span class="o">:</span><span class="w"> </span><span class="kt">string</span><span class="p">[])</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="p">(</span><span class="nx">subdomains</span><span class="w"> </span><span class="o">??</span><span class="w"> </span><span class="p">[])</span>
<span class="w">    </span><span class="p">.</span><span class="nx">concat</span><span class="p">([</span><span class="s2">"@"</span><span class="p">,</span><span class="w"> </span><span class="s2">"*"</span><span class="p">])</span>
<span class="w">    </span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">subdomain</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span>
<span class="w">      </span><span class="k">this</span><span class="p">.</span><span class="nx">MX</span><span class="p">(</span><span class="nx">subdomain</span><span class="p">,</span><span class="w"> </span><span class="p">[</span>
<span class="w">        </span><span class="s2">"10 in1-smtp.messagingengine.com."</span><span class="p">,</span>
<span class="w">        </span><span class="s2">"20 in2-smtp.messagingengine.com."</span><span class="p">,</span>
<span class="w">      </span><span class="p">])</span>
<span class="w">    </span><span class="p">);</span>
<span class="w">  </span><span class="k">this</span><span class="p">.</span><span class="nx">TXT</span><span class="p">(</span><span class="s2">"@"</span><span class="p">,</span><span class="w"> </span><span class="s2">"v=spf1 include:spf.messagingengine.com ~all"</span><span class="p">);</span>
<span class="w">  </span><span class="p">[</span><span class="s2">"mesmtp"</span><span class="p">,</span><span class="w"> </span><span class="s2">"fm1"</span><span class="p">,</span><span class="w"> </span><span class="s2">"fm2"</span><span class="p">,</span><span class="w"> </span><span class="s2">"fm3"</span><span class="p">].</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">dk</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span>
<span class="w">    </span><span class="k">this</span><span class="p">.</span><span class="nx">CNAME</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">dk</span><span class="si">}</span><span class="sb">._domainkey`</span><span class="p">,</span><span class="w"> </span><span class="sb">`</span><span class="si">${</span><span class="nx">dk</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="k">this</span><span class="p">.</span><span class="nx">name</span><span class="si">}</span><span class="sb">.dkim.fmhosted.com.`</span><span class="p">)</span>
<span class="w">  </span><span class="p">);</span>
<span class="w">  </span><span class="k">this</span><span class="p">.</span><span class="nx">TXT</span><span class="p">(</span><span class="s2">"_dmarc"</span><span class="p">,</span><span class="w"> </span><span class="s2">"v=DMARC1; p=none; sp=none"</span><span class="p">);</span>
<span class="w">  </span><span class="k">return</span><span class="w"> </span><span class="k">this</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>I encourage you to browse the <a href="https://github.com/vincentbernat/cdktf-take1/tree/51dfb3f4dbeb">repository</a> if you need more
information.</p>
<h2 id="about-pulumi">About Pulumi</h2>
<p>My first attempt around <em>Terraform</em> was to use <a href="https://www.pulumi.com/" title="Pulumi: universal infrastructure as code">Pulumi</a>. You can find this
attempt on <a href="https://github.com/vincentbernat/pulumi-take1">GitHub</a>. This is quite similar to what I currently do
with <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em>. The main difference is that I am using Python instead of TypeScript
because I was not familiar with TypeScript at the time.<sup id="fnref:python"><a class="footnote-ref" href="#fn:python">2</a></sup></p>
<p><em>Pulumi</em> predates <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em> and it uses a slightly different approach. <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em>
generates a <em>Terraform</em> configuration (in JSON format instead of <abbr title="HashiCorp Configuration Language">HCL</abbr>), delegating
planning, state management, and deployment to <em>Terraform</em>. It is therefore bound
to the limitations of what can be expressed by <em>Terraform</em>, notably when you
need to transform data obtained from one resource to another.<sup id="fnref:transform"><a class="footnote-ref" href="#fn:transform">3</a></sup>
<em>Pulumi</em> needs specific providers for each resource. Many <em>Pulumi</em> providers are
thin wrappers encapsulating <em>Terraform</em> providers.</p>
<p>While <em>Pulumi</em> provides a good user experience, I switched to <abbr title="Cloud Development Kit for Terraform">CDKTF</abbr> because
writing providers for <em>Pulumi</em> is a chore. <abbr title="Cloud Development Kit for Terraform">CDKTF</abbr> does not require such a step.
Outside the big players (AWS, Azure, and Google Cloud), the existence, quality,
and freshness of the <em>Pulumi</em> providers are inconsistent. Most providers rely on
a <em>Terraform</em> provider and they may lag a few versions behind, miss a few
resources, or have a few bugs of their own.</p>
<p>When a provider does not exist, you can write one with the help of the
<a href="https://github.com/pulumi/pulumi-terraform-bridge">pulumi-terraform-bridge</a> library. The <em>Pulumi</em> project provides a
<a href="https://github.com/pulumi/pulumi-tf-provider-boilerplate">boilerplate</a> for this purpose. I had a bad experience with it when writing
providers for <a href="https://github.com/vincentbernat/pulumi-gandi-old" title="Deprecated Pulumi provider for Gandi">Gandi</a> and <a href="https://github.com/vincentbernat/pulumi-vultr" title="Pulumi provider for Vultr">Vultr</a>: the
<code>Makefile</code> <a href="https://github.com/pulumi/pulumi-tf-provider-boilerplate/pull/51" title="PR #51: Do not install pulumi automatically">automatically installs <em>Pulumi</em></a> using a <code>curl | sh</code>
pattern and <a href="https://github.com/pulumi/pulumi-tf-provider-boilerplate/pull/52" title="PR #52: Fix Makefile to work with a POSIX shell">does not work with <code>/bin/sh</code></a>. There is a lack of
interest for community-based contributions<sup id="fnref:maint"><a class="footnote-ref" href="#fn:maint">4</a></sup> or even for <a href="https://twitter.com/briggsl/status/1473009153983623176">providers for
smaller players</a>.</p>
<h1 id="nixos-nixops">NixOS &amp; NixOps</h1>
<p><a href="https://nix.dev/manual/nix/stable/language/index.html" title="Nix, the language">Nix</a> is a functional, purely-functional programming language.
<a href="https://nix.dev/manual/nix/stable/introduction.html" title="Nix, the package manager">Nix</a> is also the name of the package manager that is built on top of
the <em>Nix</em> language. It allows users to declaratively install packages.
<a href="https://github.com/NixOS/nixpkgs">nixpkgs</a> is a repository of packages. You can <a href="https://nix.dev/manual/nix/stable/installation/multi-user.html">install <em>Nix</em></a> on
top of a regular Linux distribution. If you want more details, a good resource
is the <a href="https://nixos.org/">official website</a>, and notably the <a href="https://nixos.org/learn/">“learn” section</a>. There is a steep learning curve, but the reward is tremendous.</p>
<h2 id="nixos-declarative-linux-distribution">NixOS: declarative Linux distribution</h2>
<p><a href="https://nixos.org/" title="Nix: reproducible builds and deployments">NixOS</a> is a Linux distribution built on top of the <em>Nix</em> package manager.
Here is a configuration snippet to add some packages:</p>
<div class="language-nix codehilite"><pre><span/><code>environment<span class="o">.</span><span class="ss">systemPackages</span> <span class="o">=</span> <span class="k">with</span> pkgs<span class="p">;</span>
  <span class="p">[</span>
    bat
    htop
    liboping
    mg
    mtr
    ncdu
    tmux
  <span class="p">];</span>
</code></pre></div>

<p>It is possible to alter an existing derivation<sup id="fnref:derivation"><a class="footnote-ref" href="#fn:derivation">5</a></sup> to use a different version, enable a
specific feature, or apply a patch. Here is how I enable and configure <em>Nginx</em>
to disable the stream module, add the <a href="https://github.com/google/ngx_brotli">Brotli compression module</a>, and add the
<a href="https://github.com/masonicboom/ipscrub">IP address anonymizer module</a>. Moreover, instead of using <em>OpenSSL 3</em>, I keep
using <em>OpenSSL 1.1</em>.<sup id="fnref:openssl"><a class="footnote-ref" href="#fn:openssl">6</a></sup></p>
<div class="language-nix codehilite"><pre><span/><code>services<span class="o">.</span><span class="ss">nginx</span> <span class="o">=</span> <span class="p">{</span>
  <span class="ss">enable</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>

  <span class="ss">package</span> <span class="o">=</span> <span class="p">(</span>pkgs<span class="o">.</span>nginxStable<span class="o">.</span>override <span class="p">{</span>
    <span class="ss">withStream</span> <span class="o">=</span> <span class="no">false</span><span class="p">;</span>
    <span class="ss">modules</span> <span class="o">=</span> <span class="k">with</span> pkgs<span class="o">.</span>nginxModules<span class="p">;</span> <span class="p">[</span>
      brotli
      ipscrub
    <span class="p">];</span>
    <span class="ss">openssl</span> <span class="o">=</span> pkgs<span class="o">.</span>openssl_1_1<span class="p">;</span>
  <span class="p">});</span>
</code></pre></div>

<p>If you need to add some patches, it is also possible. Here are the patches I
added in 2019 to circumvent the <a href="https://github.com/Netflix/security-bulletins/blob/master/advisories/third-party/2019-002.md">DoS vulnerabilities in <em>Nginx</em></a>
until they were fixed in <em>NixOS</em>:<sup id="fnref:security"><a class="footnote-ref" href="#fn:security">7</a></sup></p>
<div class="language-nix codehilite"><pre><span/><code>services<span class="o">.</span>nginx<span class="o">.</span><span class="ss">package</span> <span class="o">=</span> pkgs<span class="o">.</span>nginxStable<span class="o">.</span>overrideAttrs <span class="p">(</span>old<span class="p">:</span> <span class="p">{</span>
  <span class="ss">patches</span> <span class="o">=</span> old<span class="o">.</span>patches <span class="o">++</span> <span class="p">[</span>
    <span class="c1"># HTTP/2: reject zero length headers with PROTOCOL_ERROR.</span>
    <span class="p">(</span>pkgs<span class="o">.</span>fetchpatch <span class="p">{</span>
      <span class="ss">url</span> <span class="o">=</span> <span class="l">https://github.com/nginx/nginx/commit/dbdd</span><span class="p">[</span><span class="err">…</span><span class="p">]</span><span class="o">.</span>patch<span class="p">;</span>
      <span class="ss">sha256</span> <span class="o">=</span> <span class="s2">"a48190[…]"</span><span class="p">;</span>
    <span class="p">})</span>
    <span class="c1"># HTTP/2: limited number of DATA frames.</span>
    <span class="p">(</span>pkgs<span class="o">.</span>fetchpatch <span class="p">{</span>
      <span class="ss">url</span> <span class="o">=</span> <span class="l">https://github.com/nginx/nginx/commit/94c5</span><span class="p">[</span><span class="err">…</span><span class="p">]</span><span class="o">.</span>patch<span class="p">;</span>
      <span class="ss">sha256</span> <span class="o">=</span> <span class="s2">"af591a[…]"</span><span class="p">;</span>
    <span class="p">})</span>
    <span class="c1">#  HTTP/2: limited number of PRIORITY frames.</span>
    <span class="p">(</span>pkgs<span class="o">.</span>fetchpatch <span class="p">{</span>
      <span class="ss">url</span> <span class="o">=</span> <span class="l">https://github.com/nginx/nginx/commit/39bb</span><span class="p">[</span><span class="err">…</span><span class="p">]</span><span class="o">.</span>patch<span class="p">;</span>
      <span class="ss">sha256</span> <span class="o">=</span> <span class="s2">"1ad8fe[…]"</span><span class="p">;</span>
    <span class="p">})</span>
  <span class="p">];</span>
<span class="p">});</span>
</code></pre></div>

<p>If you are interested, have a look at my relatively small configuration:
<a href="https://github.com/vincentbernat/nixops-take1/blob/7e583aca7906/tags/common.nix"><code>common.nix</code></a> contains the configuration to be applied to any host
(SSH, users, common software packages), <a href="https://github.com/vincentbernat/nixops-take1/blob/7e583aca7906/tags/web.nix"><code>web.nix</code></a> contains the
configuration for the web servers, <a href="https://github.com/vincentbernat/nixops-take1/blob/7e583aca7906/tags/isso.nix"><code>isso.nix</code></a> runs <a href="/en/blog/2020-docker-nixos-isso" title="Running Isso on NixOS in a Docker container">Isso</a> into a
<em>systemd</em> container.</p>
<h2 id="nixops-nixos-deployment-tool">NixOps: NixOS deployment tool</h2>
<p>On a single node, <em>NixOS</em> configuration is in the <code>/etc/nixos/configuration.nix</code>
file. After modifying it, you have to run <code>nixos-rebuild switch</code>. <em>Nix</em> fetches
all possible dependencies from the binary cache and builds the remaining
packages. It creates a new entry in the boot loader menu and activates the new
configuration.</p>
<p>To manage several nodes, there exist several options, including <a href="https://github.com/NixOS/nixops" title="NixOps:  tool for deploying to NixOS machines in a network or cloud">NixOps</a>,
<a href="https://github.com/serokell/deploy-rs" title="simple multi-profile Nix flake deploy tool">deploy-rs</a>, <a href="https://github.com/nix-community/colmena" title="Simple, stateless NixOS deployment tool">Colmena</a>, and <a href="https://github.com/DBCDK/morph/" title="NixOS deployment tool">morph</a>. I do not know all of them, but from
my point of view, the differences are not that important. It is also possible to
build such a tool yourself as <em>Nix</em> provides the most important building blocks:
<code>nix build</code> and <code>nix copy</code>. <em>NixOps</em> is one of the first tools available but I
encourage you to explore the alternatives.</p>
<p><em>NixOps</em> configuration is written in <em>Nix</em>. Here is a simplified configuration
to deploy <code>znc01.luffy.cx</code>, <code>web01.luffy.cx</code>, and <code>web02.luffy.cx</code>, with the
help of the <code>server</code> and <code>web</code> functions:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="k">let</span>
  <span class="ss">server</span> <span class="o">=</span> hardware<span class="p">:</span> name<span class="p">:</span> imports<span class="p">:</span> <span class="p">{</span>
    deployment<span class="o">.</span><span class="ss">targetHost</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span>name<span class="si">}</span><span class="s2">.luffy.cx"</span><span class="p">;</span>
    networking<span class="o">.</span><span class="ss">hostName</span> <span class="o">=</span> name<span class="p">;</span>
    networking<span class="o">.</span><span class="ss">domain</span> <span class="o">=</span> <span class="s2">"luffy.cx"</span><span class="p">;</span>
    <span class="ss">imports</span> <span class="o">=</span> <span class="p">[</span> <span class="p">(</span><span class="l">./hardware/.</span> <span class="o">+</span> <span class="s2">"/</span><span class="si">${</span>hardware<span class="si">}</span><span class="s2">.nix"</span><span class="p">)</span> <span class="p">]</span> <span class="o">++</span> imports<span class="p">;</span>
  <span class="p">};</span>
  <span class="ss">web</span> <span class="o">=</span> hardware<span class="p">:</span> idx<span class="p">:</span> imports<span class="p">:</span>
    server hardware <span class="s2">"web</span><span class="si">${</span>lib<span class="o">.</span>fixedWidthNumber <span class="mi">2</span> idx<span class="si">}</span><span class="s2">"</span> <span class="p">([</span> <span class="l">./web.nix</span> <span class="p">]</span> <span class="o">++</span> imports<span class="p">);</span>
<span class="k">in</span> <span class="p">{</span>
  network<span class="o">.</span><span class="ss">description</span> <span class="o">=</span> <span class="s2">"Luffy infrastructure"</span><span class="p">;</span>
  network<span class="o">.</span><span class="ss">enableRollback</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
  <span class="ss">defaults</span> <span class="o">=</span> <span class="nb">import</span> <span class="l">./common.nix</span><span class="p">;</span>
  <span class="ss">znc01</span> <span class="o">=</span> server <span class="s2">"exoscale"</span> <span class="p">[</span> <span class="l">./znc.nix</span> <span class="p">];</span>
  <span class="ss">web01</span> <span class="o">=</span> web <span class="s2">"hetzner"</span> <span class="mi">1</span> <span class="p">[</span> <span class="l">./isso.nix</span> <span class="p">];</span>
  <span class="ss">web02</span> <span class="o">=</span> web <span class="s2">"hetzner"</span> <span class="mi">2</span> <span class="p">[];</span>
<span class="p">}</span>
</code></pre></div>

<h1 id="tying-everything-together-with-nix">Tying everything together with Nix</h1>
<p>The <em>Nix</em> ecosystem is a unified solution to the various problems around
software and configuration management. A very interesting feature is the
<a href="https://web.archive.org/web/2022/https://nixos.org/guides/declarative-and-reproducible-developer-environments.html">declarative and reproducible developer environments</a>. This is similar to
Python virtual environments, except it is not language-specific.</p>
<h2 id="brief-introduction-to-nix-flakes">Brief introduction to Nix flakes</h2>
<p>I am using <a href="https://wiki.nixos.org/wiki/Flakes" title="Flakes on NixOS wiki">flakes</a>, a new <em>Nix</em> feature improving reproducibility by pinning
all dependencies and making the build hermetic. While the feature is marked as
experimental,<sup id="fnref:experimental"><a class="footnote-ref" href="#fn:experimental">8</a></sup> it is widely used and you may see <code>flake.nix</code> and
<code>flake.lock</code> at the root of some repositories.</p>
<p>As a short example, here is the <code>flake.nix</code> content shipped with <a href="/en/blog/2013-snimpy" title="Snimpy: SNMP &amp; Python">Snimpy</a>, an
interactive SNMP tool for Python relying on <a href="https://www.ibr.cs.tu-bs.de/projects/libsmi/" title="libsmi: library to Access SMI MIB Information">libsmi</a>, a C library:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="p">{</span>
  <span class="ss">inputs</span> <span class="o">=</span> <span class="p">{</span>
    nixpkgs<span class="o">.</span><span class="ss">url</span> <span class="o">=</span> <span class="s2">"nixpkgs"</span><span class="p">;</span>
    flake-utils<span class="o">.</span><span class="ss">url</span> <span class="o">=</span> <span class="s2">"github:numtide/flake-utils"</span><span class="p">;</span>
  <span class="p">};</span>
  <span class="ss">outputs</span> <span class="o">=</span> <span class="p">{</span> self<span class="p">,</span> <span class="o">...</span> <span class="p">}@</span>inputs<span class="p">:</span>
    inputs<span class="o">.</span>flake-utils<span class="o">.</span>lib<span class="o">.</span>eachDefaultSystem <span class="p">(</span>system<span class="p">:</span>
      <span class="k">let</span>
        <span class="ss">pkgs</span> <span class="o">=</span> inputs<span class="o">.</span>nixpkgs<span class="o">.</span>legacyPackages<span class="o">.</span><span class="s2">"</span><span class="si">${</span>system<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
      <span class="k">in</span>
      <span class="p">{</span>
        <span class="c1"># nix build</span>
        packages<span class="o">.</span><span class="ss">default</span> <span class="o">=</span> pkgs<span class="o">.</span>python3Packages<span class="o">.</span>buildPythonPackage <span class="p">{</span>
          <span class="ss">name</span> <span class="o">=</span> <span class="s2">"snimpy"</span><span class="p">;</span>
          <span class="ss">src</span> <span class="o">=</span> self<span class="p">;</span>
          <span class="ss">preConfigure</span> <span class="o">=</span> <span class="s s-Multiline">''echo "1.0.0-0-000000000000" &gt; version.txt''</span><span class="p">;</span>
          <span class="ss">checkPhase</span> <span class="o">=</span> <span class="s2">"pytest"</span><span class="p">;</span>
          <span class="ss">checkInputs</span> <span class="o">=</span> <span class="k">with</span> pkgs<span class="o">.</span>python3Packages<span class="p">;</span> <span class="p">[</span> pytest mock coverage <span class="p">];</span>
          <span class="ss">propagatedBuildInputs</span> <span class="o">=</span> <span class="k">with</span> pkgs<span class="o">.</span>python3Packages<span class="p">;</span> <span class="p">[</span> cffi pysnmp ipython <span class="p">];</span>
          <span class="ss">buildInputs</span> <span class="o">=</span> <span class="p">[</span> pkgs<span class="o">.</span>libsmi <span class="p">];</span>
        <span class="p">};</span>
        <span class="c1"># nix run + nix shell</span>
        apps<span class="o">.</span><span class="ss">default</span> <span class="o">=</span> <span class="p">{</span> 
          <span class="ss">type</span> <span class="o">=</span> <span class="s2">"app"</span><span class="p">;</span>
          <span class="ss">program</span> <span class="o">=</span> <span class="s2">"</span><span class="si">${</span>self<span class="o">.</span>packages<span class="o">.</span><span class="s2">"</span><span class="si">${</span>system<span class="si">}</span><span class="s2">"</span><span class="o">.</span>default<span class="si">}</span><span class="s2">/bin/snimpy"</span><span class="p">;</span>
        <span class="p">};</span>
        <span class="c1"># nix develop</span>
        devShells<span class="o">.</span><span class="ss">default</span> <span class="o">=</span> pkgs<span class="o">.</span>mkShell <span class="p">{</span>
          <span class="ss">name</span> <span class="o">=</span> <span class="s2">"snimpy-dev"</span><span class="p">;</span>
          <span class="ss">buildInputs</span> <span class="o">=</span> <span class="p">[</span>
            self<span class="o">.</span>packages<span class="o">.</span><span class="s2">"</span><span class="si">${</span>system<span class="si">}</span><span class="s2">"</span><span class="o">.</span>default<span class="o">.</span>inputDerivation
            pkgs<span class="o">.</span>python3Packages<span class="o">.</span>ipython
          <span class="p">];</span>
        <span class="p">};</span>
      <span class="p">});</span>
<span class="p">}</span>
</code></pre></div>

<p>If you have <em>Nix</em> installed on your system:</p>
<ul>
<li><code>nix run github:vincentbernat/snimpy</code> runs <em>Snimpy</em>,</li>
<li><code>nix shell github:vincentbernat/snimpy</code> provides a shell with <em>Snimpy</em> ready-to-use,</li>
<li><code>nix build github:vincentbernat/snimpy</code> builds the Python package, tests included, and</li>
<li><code>nix develop .</code> provides a shell to hack around <em>Snimpy</em>—when run from a fresh checkout.<sup id="fnref:checkout"><a class="footnote-ref" href="#fn:checkout">9</a></sup></li>
</ul>
<p>For more information about <em>Nix flakes</em>, have a look at <a href="https://www.tweag.io/blog/2020-05-25-flakes/" title="Nix Flakes, Part 1: An introduction and tutorial">the tutorial from
Tweag</a>.</p>
<h2 id="nix-and-cdktf">Nix and <abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></h2>
<p>At the root of the <a href="https://github.com/vincentbernat/cdktf-take1/tree/51dfb3f4dbeb">repository I use for <abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></a>, there is a
<a href="https://github.com/vincentbernat/cdktf-take1/blob/51dfb3f4dbeb/flake.nix"><code>flake.nix</code></a> file to set up a shell with <em>Terraform</em> and
<em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em> installed and with the appropriate environment variables to automate my
infrastructure.</p>
<p><em>Terraform</em> is already packaged in <em>nixpkgs</em>. However, I need to apply a patch
on top of the <em>Gandi</em> provider. Not a problem with <em>Nix</em>!</p>
<div class="language-nix codehilite"><pre><span/><code><span class="ss">terraform</span> <span class="o">=</span> pkgs<span class="o">.</span>terraform<span class="o">.</span>withPlugins <span class="p">(</span>p<span class="p">:</span> <span class="p">[</span>
  p<span class="o">.</span>aws
  p<span class="o">.</span>hcloud
  p<span class="o">.</span>vultr
  <span class="p">(</span>p<span class="o">.</span>gandi<span class="o">.</span>overrideAttrs
    <span class="p">(</span>old<span class="p">:</span> <span class="p">{</span>
      <span class="ss">src</span> <span class="o">=</span> pkgs<span class="o">.</span>fetchFromGitHub <span class="p">{</span>
        <span class="ss">owner</span> <span class="o">=</span> <span class="s2">"vincentbernat"</span><span class="p">;</span>
        <span class="ss">repo</span> <span class="o">=</span> <span class="s2">"terraform-provider-gandi"</span><span class="p">;</span>
        <span class="ss">rev</span> <span class="o">=</span> <span class="s2">"feature/livedns-key"</span><span class="p">;</span>
        <span class="ss">hash</span> <span class="o">=</span> <span class="s2">"sha256-V16BIjo5/rloQ1xTQrdd0snoq1OPuDh3fQNW7kiv/kQ="</span><span class="p">;</span>
      <span class="p">};</span>
    <span class="p">}))</span>
<span class="p">]);</span>
</code></pre></div>

<p><em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em> is written in TypeScript. I have a
<a href="https://github.com/vincentbernat/cdktf-take1/blob/51dfb3f4dbeb/package.json"><code>package.json</code></a> file with all the dependencies
needed, including the ones to use TypeScript as the language to define
infrastructure:</p>
<div class="language-json codehilite"><pre><span/><code><span class="p">{</span>
<span class="w">  </span><span class="nt">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"cdktf-take1"</span><span class="p">,</span>
<span class="w">  </span><span class="nt">"version"</span><span class="p">:</span><span class="w"> </span><span class="s2">"1.0.0"</span><span class="p">,</span>
<span class="w">  </span><span class="nt">"main"</span><span class="p">:</span><span class="w"> </span><span class="s2">"main.js"</span><span class="p">,</span>
<span class="w">  </span><span class="nt">"types"</span><span class="p">:</span><span class="w"> </span><span class="s2">"main.ts"</span><span class="p">,</span>
<span class="w">  </span><span class="nt">"private"</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span>
<span class="w">  </span><span class="nt">"dependencies"</span><span class="p">:</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">"@types/node"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^14.18.30"</span><span class="p">,</span>
<span class="w">    </span><span class="nt">"cdktf"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^0.13.3"</span><span class="p">,</span>
<span class="w">    </span><span class="nt">"cdktf-cli"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^0.13.3"</span><span class="p">,</span>
<span class="w">    </span><span class="nt">"constructs"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^10.1.151"</span><span class="p">,</span>
<span class="w">    </span><span class="nt">"eslint"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^8.27.0"</span><span class="p">,</span>
<span class="w">    </span><span class="nt">"prettier"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^2.7.1"</span><span class="p">,</span>
<span class="w">    </span><span class="nt">"ts-node"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^10.9.1"</span><span class="p">,</span>
<span class="w">    </span><span class="nt">"typescript"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^3.9.10"</span><span class="p">,</span>
<span class="w">    </span><span class="nt">"typescript-language-server"</span><span class="p">:</span><span class="w"> </span><span class="s2">"^2.1.0"</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>I use <a href="https://yarnpkg.com/" title="Yarn package manager">Yarn</a> to get a <a href="https://github.com/vincentbernat/cdktf-take1/blob/51dfb3f4dbeb/yarn.lock"><code>yarn.lock</code></a> file that can be
used directly to declare a derivation containing all the dependencies:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="ss">nodeEnv</span> <span class="o">=</span> pkgs<span class="o">.</span>mkYarnModules <span class="p">{</span>
  <span class="ss">pname</span> <span class="o">=</span> <span class="s2">"cdktf-take1-js-modules"</span><span class="p">;</span>
  <span class="ss">version</span> <span class="o">=</span> <span class="s2">"1.0.0"</span><span class="p">;</span>
  <span class="ss">packageJSON</span> <span class="o">=</span> <span class="l">./package.json</span><span class="p">;</span>
  <span class="ss">yarnLock</span> <span class="o">=</span> <span class="l">./yarn.lock</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div>

<p>The next step is to generate the <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em> providers from the <em>Terraform</em> providers
and turn them into a derivation:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="ss">cdktfProviders</span> <span class="o">=</span> pkgs<span class="o">.</span>stdenvNoCC<span class="o">.</span>mkDerivation <span class="p">{</span>
  <span class="ss">name</span> <span class="o">=</span> <span class="s2">"cdktf-providers"</span><span class="p">;</span>
  <span class="ss">nativeBuildInputs</span> <span class="o">=</span> <span class="p">[</span>
    pkgs<span class="o">.</span>nodejs
    terraform
  <span class="p">];</span>
  <span class="ss">src</span> <span class="o">=</span> nix-filter <span class="p">{</span>
    <span class="ss">root</span> <span class="o">=</span> <span class="l">./.</span><span class="p">;</span>
    <span class="ss">include</span> <span class="o">=</span> <span class="p">[</span> <span class="l">./cdktf.json</span> <span class="l">./tsconfig.json</span> <span class="p">];</span>
  <span class="p">};</span>
  <span class="ss">buildPhase</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">    export HOME=$(mktemp -d)</span>
<span class="s s-Multiline">    export CHECKPOINT_DISABLE=1</span>
<span class="s s-Multiline">    export DISABLE_VERSION_CHECK=1</span>
<span class="s s-Multiline">    export PATH=</span><span class="si">${</span>nodeEnv<span class="si">}</span><span class="s s-Multiline">/node_modules/.bin:$PATH</span>
<span class="s s-Multiline">    ln -nsf </span><span class="si">${</span>nodeEnv<span class="si">}</span><span class="s s-Multiline">/node_modules node_modules</span>

<span class="s s-Multiline">    # Build all providers we have in terraform</span>
<span class="s s-Multiline">    for provider in $(cd </span><span class="si">${</span>terraform<span class="si">}</span><span class="s s-Multiline">/libexec/terraform-providers; echo */*/*/*); do</span>
<span class="s s-Multiline">      version=</span><span class="se">''$</span><span class="s s-Multiline">{provider##*/}</span>
<span class="s s-Multiline">      provider=</span><span class="se">''$</span><span class="s s-Multiline">{provider%/*}</span>
<span class="s s-Multiline">      echo "Build $provider@$version"</span>
<span class="s s-Multiline">      cdktf provider add --force-local $provider@$version | cat</span>
<span class="s s-Multiline">    done</span>
<span class="s s-Multiline">    echo "Compile TS → JS"</span>
<span class="s s-Multiline">    tsc</span>
<span class="s s-Multiline">  ''</span><span class="p">;</span>
  <span class="ss">installPhase</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">    mv .gen $out</span>
<span class="s s-Multiline">    ln -nsf </span><span class="si">${</span>nodeEnv<span class="si">}</span><span class="s s-Multiline">/node_modules $out/node_modules</span>
<span class="s s-Multiline">  ''</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div>

<p>Finally, we can define the development environment:</p>
<div class="language-nix codehilite"><pre><span/><code>devShells<span class="o">.</span><span class="ss">default</span> <span class="o">=</span> pkgs<span class="o">.</span>mkShell <span class="p">{</span>
  <span class="ss">name</span> <span class="o">=</span> <span class="s2">"cdktf-take1"</span><span class="p">;</span>
  <span class="ss">buildInputs</span> <span class="o">=</span> <span class="p">[</span>
    pkgs<span class="o">.</span>nodejs
    pkgs<span class="o">.</span>yarn
    terraform
  <span class="p">];</span>
  <span class="ss">shellHook</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">    # No telemetry</span>
<span class="s s-Multiline">    export CHECKPOINT_DISABLE=1</span>
<span class="s s-Multiline">    # No autoinstall of plugins</span>
<span class="s s-Multiline">    export CDKTF_DISABLE_PLUGIN_CACHE_ENV=1</span>
<span class="s s-Multiline">    # Do not check version</span>
<span class="s s-Multiline">    export DISABLE_VERSION_CHECK=1</span>
<span class="s s-Multiline">    # Access to node modules</span>
<span class="s s-Multiline">    export PATH=$PWD/node_modules/.bin:$PATH</span>
<span class="s s-Multiline">    ln -nsf </span><span class="si">${</span>nodeEnv<span class="si">}</span><span class="s s-Multiline">/node_modules node_modules</span>
<span class="s s-Multiline">    ln -nsf </span><span class="si">${</span>cdktfProviders<span class="si">}</span><span class="s s-Multiline"> .gen</span>

<span class="s s-Multiline">    # Credentials</span>
<span class="s s-Multiline">    for p in \</span>
<span class="s s-Multiline">      njf.nznmba.pbz/Nqzvavfgengbe \</span>
<span class="s s-Multiline">      urgmare.pbz/ivaprag@oreang.pu \</span>
<span class="s s-Multiline">      ihyge.pbz/ihyge@ivaprag.oreang.pu; do</span>
<span class="s s-Multiline">        eval $(pass show $(echo $p | tr 'A-Za-z' 'N-ZA-Mn-za-m') | grep '^export')</span>
<span class="s s-Multiline">    done</span>
<span class="s s-Multiline">    eval $(pass show personal/cdktf/secrets | grep '^export')</span>
<span class="s s-Multiline">    export TF_VAR_hcloudToken="$HCLOUD_TOKEN"</span>
<span class="s s-Multiline">    export TF_VAR_vultrApiKey="$VULTR_API_KEY"</span>
<span class="s s-Multiline">    unset VULTR_API_KEY HCLOUD_TOKEN</span>
<span class="s s-Multiline">  ''</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div>

<p>The derivations listed in <code>buildInputs</code> are available in the provided shell.
The content of <code>shellHook</code> is sourced when starting the shell. It sets up some
symbolic links to make the JavaScript environment built at an earlier step
available, as well as the generated <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em> providers. It also exports all the
credentials.<sup id="fnref:obfuscated"><a class="footnote-ref" href="#fn:obfuscated">10</a></sup></p>
<p>I am also using <a href="https://github.com/direnv/direnv/" title="direnv: unclutter your .profile">direnv</a> with an <a href="https://github.com/vincentbernat/cdktf-take1/blob/51dfb3f4dbeb/.envrc"><code>.envrc</code></a> to
automatically load the development environment. This also enables the
environment to be available from inside <em>Emacs</em>, notably when using <a href="https://github.com/emacs-lsp/lsp-mode/" title="Language Server Protocol Support for Emacs">lsp-mode</a>
to get TypeScript completions. Without <em>direnv</em>, <code>nix develop .</code> can activate
the environment.</p>
<p>I use the following commands to deploy the infrastructure:<sup id="fnref:cdktf-cli"><a class="footnote-ref" href="#fn:cdktf-cli">11</a></sup></p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>cdktf<span class="w"> </span>synth
<span class="gp">$ </span><span class="nb">cd</span><span class="w"> </span>cdktf.out/stacks/cdktf-take1
<span class="gp">$ </span>terraform<span class="w"> </span>plan<span class="w"> </span>--out<span class="w"> </span>plan
<span class="gp">$ </span>terraform<span class="w"> </span>apply<span class="w"> </span>plan
<span class="gp">$ </span>terraform<span class="w"> </span>output<span class="w"> </span>-json<span class="w"> </span>&gt;<span class="w"> </span>~-automation/nixops-take1/cdktf.json
</code></pre></div>

<p>The last command generates a JSON file containing various data to complete the
deployment with <em>NixOps</em>.</p>
<h2 id="nixops">NixOps</h2>
<p>The <a href="https://github.com/vincentbernat/nixops-take1/blob/7e583aca7906/cdktf.json">JSON file</a> exported by <em>Terraform</em> contains the list of servers with various
attributes:</p>
<div class="language-json codehilite"><pre><span/><code><span class="p">{</span>
<span class="w">  </span><span class="nt">"hardware"</span><span class="p">:</span><span class="w"> </span><span class="s2">"hetzner"</span><span class="p">,</span>
<span class="w">  </span><span class="nt">"ipv4Address"</span><span class="p">:</span><span class="w"> </span><span class="s2">"5.161.44.145"</span><span class="p">,</span>
<span class="w">  </span><span class="nt">"ipv6Address"</span><span class="p">:</span><span class="w"> </span><span class="s2">"2a01:4ff:f0:b91::1"</span><span class="p">,</span>
<span class="w">  </span><span class="nt">"name"</span><span class="p">:</span><span class="w"> </span><span class="s2">"web05.luffy.cx"</span><span class="p">,</span>
<span class="w">  </span><span class="nt">"tags"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span>
<span class="w">    </span><span class="s2">"web"</span><span class="p">,</span>
<span class="w">    </span><span class="s2">"continent:NA"</span><span class="p">,</span>
<span class="w">    </span><span class="s2">"continent:SA"</span>
<span class="w">  </span><span class="p">]</span>
<span class="p">}</span>
</code></pre></div>

<p>In <a href="https://github.com/vincentbernat/nixops-take1/blob/7e583aca7906/network.nix"><code>network.nix</code></a>, this list is imported and
transformed into an attribute set describing the servers. A simplified version
looks like this:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="k">let</span>
  <span class="ss">lib</span> <span class="o">=</span> inputs<span class="o">.</span>nixpkgs<span class="o">.</span>lib<span class="p">;</span>
  <span class="ss">shortName</span> <span class="o">=</span> name<span class="p">:</span> <span class="nb">builtins</span><span class="o">.</span>elemAt <span class="p">(</span>lib<span class="o">.</span>splitString <span class="s2">"."</span> name<span class="p">)</span> <span class="mi">0</span><span class="p">;</span>
  <span class="ss">domainName</span> <span class="o">=</span> name<span class="p">:</span> lib<span class="o">.</span>concatStringsSep <span class="s2">"."</span> <span class="p">(</span><span class="nb">builtins</span><span class="o">.</span>tail <span class="p">(</span>lib<span class="o">.</span>splitString <span class="s2">"."</span> name<span class="p">));</span>
  <span class="ss">server</span> <span class="o">=</span> hardware<span class="p">:</span> name<span class="p">:</span> imports<span class="p">:</span> <span class="p">{</span>
    <span class="ss">networking</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">hostName</span> <span class="o">=</span> shortName name<span class="p">;</span>
      <span class="ss">domain</span> <span class="o">=</span> domainName name<span class="p">;</span>
    <span class="p">};</span>
    deployment<span class="o">.</span><span class="ss">targetHost</span> <span class="o">=</span> name<span class="p">;</span>
    <span class="ss">imports</span> <span class="o">=</span> <span class="p">[</span> <span class="p">(</span><span class="l">./hardware/.</span> <span class="o">+</span> <span class="s2">"/</span><span class="si">${</span>hardware<span class="si">}</span><span class="s2">.nix"</span><span class="p">)</span> <span class="p">]</span> <span class="o">++</span> imports<span class="p">;</span>
  <span class="p">};</span>
  <span class="ss">cdktf-servers-json</span> <span class="o">=</span> <span class="p">(</span>lib<span class="o">.</span>importJSON <span class="l">./cdktf.json</span><span class="p">)</span><span class="o">.</span>servers<span class="o">.</span>value<span class="p">;</span>
  <span class="ss">cdktf-servers</span> <span class="o">=</span> <span class="nb">map</span>
    <span class="p">(</span>s<span class="p">:</span>
      <span class="k">let</span>
        <span class="ss">tags-maybe-import</span> <span class="o">=</span> <span class="nb">map</span> <span class="p">(</span>t<span class="p">:</span> <span class="l">./.</span> <span class="o">+</span> <span class="s2">"/</span><span class="si">${</span>t<span class="si">}</span><span class="s2">.nix"</span><span class="p">)</span> s<span class="o">.</span>tags<span class="p">;</span>
        <span class="ss">tags-import</span> <span class="o">=</span> <span class="nb">builtins</span><span class="o">.</span>filter <span class="p">(</span>t<span class="p">:</span> <span class="nb">builtins</span><span class="o">.</span>pathExists t<span class="p">)</span> tags-maybe-import<span class="p">;</span>
      <span class="k">in</span>
      <span class="p">{</span>
        <span class="ss">name</span> <span class="o">=</span> shortName s<span class="o">.</span>name<span class="p">;</span>
        <span class="ss">value</span> <span class="o">=</span> server s<span class="o">.</span>hardware s<span class="o">.</span>name tags-import<span class="p">;</span>
      <span class="p">})</span>
    cdktf-servers-json<span class="p">;</span>
<span class="k">in</span>
<span class="p">{</span>
  <span class="o">//</span> <span class="p">[</span><span class="err">…</span><span class="p">]</span>
<span class="p">}</span> <span class="o">//</span> <span class="nb">builtins</span><span class="o">.</span>listToAttrs cdktf-servers
</code></pre></div>

<p>For <code>web05</code>, this expands to:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="ss">web05</span> <span class="o">=</span> <span class="p">{</span>
  <span class="ss">networking</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">hostName</span> <span class="o">=</span> <span class="s2">"web05"</span><span class="p">;</span>
    <span class="ss">domainName</span> <span class="o">=</span> <span class="s2">"luffy.cx"</span><span class="p">;</span>
  <span class="p">};</span>
  deployment<span class="o">.</span><span class="ss">targetHost</span> <span class="o">=</span> <span class="s2">"web05.luffy.cx"</span><span class="p">;</span>
  <span class="ss">imports</span> <span class="o">=</span> <span class="p">[</span> <span class="l">./hardware/hetzner.nix</span> <span class="l">./web.nix</span> <span class="p">];</span>
<span class="p">};</span>
</code></pre></div>

<p>As for <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em>, at the root of the <a href="https://github.com/vincentbernat/nixops-take1/tree/7e583aca7906">repository I use for NixOps</a>,
there is a <a href="https://github.com/vincentbernat/nixops-take1/blob/7e583aca7906/flake.nix"><code>flake.nix</code></a> file to set up a shell with
<em>NixOps</em> configured. Because <em>NixOps</em> does not support rollouts, I usually use the
following commands to deploy on a single server:<sup id="fnref:disable"><a class="footnote-ref" href="#fn:disable">12</a></sup></p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>nix<span class="w"> </span>flake<span class="w"> </span>update
<span class="gp">$ </span>nixops<span class="w"> </span>deploy<span class="w"> </span>--include<span class="o">=</span>web04
<span class="gp">$ </span>./tests<span class="w"> </span>web04.luffy.cx
</code></pre></div>

<p>If the tests are OK, I deploy the remaining nodes gradually with the following
command:</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span><span class="o">(</span><span class="nb">set</span><span class="w"> </span>-e<span class="p">;</span><span class="w"> </span><span class="k">for</span><span class="w"> </span>h<span class="w"> </span><span class="k">in</span><span class="w"> </span>web<span class="o">{</span><span class="m">03</span>..06<span class="o">}</span><span class="p">;</span><span class="w"> </span><span class="k">do</span><span class="w"> </span>nixops<span class="w"> </span>deploy<span class="w"> </span>--include<span class="o">=</span><span class="nv">$h</span><span class="p">;</span><span class="w"> </span><span class="k">done</span><span class="o">)</span>
</code></pre></div>

<p><code>nixops deploy</code> rolls out all servers in parallel and therefore could cause a
short outage where all <em>Nginx</em> are down at the same time.</p>
<hr/>
<p>This post has been a work-in-progress for the past three years, with the content
being updated and refined as I experimented with different solutions. There is
still much to explore<sup id="fnref:todo"><a class="footnote-ref" href="#fn:todo">13</a></sup> but I feel there is enough content to publish now.
🎄</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:hetzner">
<p>It was an AMD Athlon 64 X2 5600+ with 2 GB of RAM and 2×400 GB disks
with software RAID. I was paying something around €59 per month for it.
While it was a good deal in 2008, by 2018 it was no longer cost-effective.
It was running on Debian Wheezy with <a href="https://web.archive.org/web/2022/http://linux-vserver.org/Welcome_to_Linux-VServer.org" title="Linux-VServer">Linux-VServer</a> for isolation, both
of which were outdated in 2018. <a class="footnote-backref" href="#fnref:hetzner" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:python">
<p>I also did not use Python because <em>Poetry</em> support in <em>Nix</em> was a bit
<a href="https://github.com/nix-community/poetry2nix/issues/750" title="Issue #750: infinite recursion">broken</a> around the time I started hacking around <em><abbr title="Cloud Development Kit for Terraform">CDKTF</abbr></em>. <a class="footnote-backref" href="#fnref:python" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:transform">
<p><em>Pulumi</em> can <a href="https://www.pulumi.com/docs/iac/concepts/inputs-outputs/apply/">apply arbitrary functions</a> with the <code>apply()</code>
method on an output. It makes it easy to transform data that are not known
during the planning stage. <em>Terraform</em> has <a href="https://developer.hashicorp.com/terraform/language/functions">functions</a> to
serve a similar purpose, but they are more limited. <a class="footnote-backref" href="#fnref:transform" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:maint">
<p>The two mentioned pull requests are not merged yet. The second one is
superseded by <a href="https://github.com/pulumi/pulumi-tf-provider-boilerplate/pull/61" title="PR #61: Explicitly specify bash as shell in Makefile">PR #61</a>, submitted two months later, which
enforces the use of <code>/bin/bash</code>. I also submitted <a href="https://github.com/pulumi/pulumi-tf-provider-boilerplate/pull/56" title="PR #56: Use go:embed instead of go generate for schema">PR #56</a>,
which was merged 4 months later and quickly <a href="https://github.com/pulumi/pulumi-tf-provider-boilerplate/pull/66" title="PR #66: Revert “Use go:embed instead of go generate for schema”">reverted</a> without
an explanation. <a class="footnote-backref" href="#fnref:maint" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:derivation">
<p>You may consider packages and derivations to be synonyms in the
<em>Nix</em> ecosystem. <a class="footnote-backref" href="#fnref:derivation" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
<li id="fn:openssl">
<p><em>OpenSSL 3</em> has outstanding <a href="https://github.com/openssl/openssl/issues/17627#issuecomment-1060123659">performance regressions</a>. <a class="footnote-backref" href="#fnref:openssl" title="Jump back to footnote 6 in the text">↩</a></p>
</li>
<li id="fn:security">
<p><em>NixOS</em> can be a bit slow to integrate patches since they need to
rebuild parts of the binary cache before releasing the fixes. In this
specific case, they were fast: the vulnerability and patches were released
on August 13<sup>th</sup> 2019 and available in <em>NixOS</em> on August 15th. As a
comparison, Debian only released the fixed version on August 22<sup>nd</sup>, which is unusually late. <a class="footnote-backref" href="#fnref:security" title="Jump back to footnote 7 in the text">↩</a></p>
</li>
<li id="fn:experimental">
<p>Because <em>flakes</em> are experimental, much of the documentation does not
use them and it is an additional aspect to learn. <a class="footnote-backref" href="#fnref:experimental" title="Jump back to footnote 8 in the text">↩</a></p>
</li>
<li id="fn:checkout">
<p>It is possible to replace <code>.</code> with <code>github:vincentbernat/snimpy</code>,
like in the other commands, but having <em>Snimpy</em> dependencies without
<em>Snimpy</em> source code is less interesting. <a class="footnote-backref" href="#fnref:checkout" title="Jump back to footnote 9 in the text">↩</a></p>
</li>
<li id="fn:obfuscated">
<p>I am using <a href="https://www.passwordstore.org/" title="The standard Unix password manager">pass</a> as a password manager. The password names are
only obfuscated to avoid spam. <a class="footnote-backref" href="#fnref:obfuscated" title="Jump back to footnote 10 in the text">↩</a></p>
</li>
<li id="fn:cdktf-cli">
<p>The <code>cdktf</code> command can wrap the <code>terraform</code> commands, but I
prefer to use them directly as they are more flexible. <a class="footnote-backref" href="#fnref:cdktf-cli" title="Jump back to footnote 11 in the text">↩</a></p>
</li>
<li id="fn:disable">
<p>If the change is risky, I <a href="https://github.com/vincentbernat/cdktf-take1/commit/606e6fb657179408b163664dd74ff5ab38b28246">disable the server</a> with <abbr title="Cloud Development Kit for Terraform">CDKTF</abbr>. This
removes it from the web service DNS records. <a class="footnote-backref" href="#fnref:disable" title="Jump back to footnote 12 in the text">↩</a></p>
</li>
<li id="fn:todo">
<p>I would like to replace <em>NixOps</em> with an alternative handling
progressive rollouts and checks. I am also considering switching to
<a href="https://developer.hashicorp.com/nomad" title="Nomad: Orchestration Made Easy">Nomad</a> or <em>Kubernetes</em> to deploy workloads. <a class="footnote-backref" href="#fnref:todo" title="Jump back to footnote 13 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">Running Isso on NixOS in a Docker container</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/en/blog/2020-docker-nixos-isso" rel="alternate"/>
    <link href="https://vincent.bernat.ch/en/blog/2020-docker-nixos-isso#isso-thread" rel="replies" type="text/html"/>
    <updated>2020-11-01T20:55:49Z</updated>
    <id>http://www.luffy.cx/en/blog/2020-docker-nixos-isso.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>This short article documents how I run <a href="https://isso-comments.de/" title="Isso: a commenting server similar to Disqus">Isso</a>, the commenting system
used by this blog, inside a Docker container on <a href="https://nixos.org/manual/nixpkgs/stable/#ssec-pkgs-dockerTools-buildImage">NixOS</a>, a Linux
distribution built on top of Nix. Nix is a declarative package
manager for Linux and other Unix systems.</p>
<hr/>
<p><del>While NixOS 20.09 includes a <a href="https://github.com/NixOS/nixpkgs/blob/release-20.09/pkgs/servers/isso/default.nix">derivation for Isso</a>, it is
unfortunately broken and relies on Python 2.</del> As I am also using a fork
of Isso, I have built my own derivation, heavily inspired by the one
in <a href="https://github.com/NixOS/nixpkgs/blob/master/pkgs/by-name/is/isso/package.nix">master</a>:<sup id="fnref:alternative"><a class="footnote-ref" href="#fn:alternative">1</a></sup></p>
<div class="language-nix codehilite"><pre><span/><code><span class="ss">issoPackage</span> <span class="o">=</span> <span class="k">with</span> pkgs<span class="o">.</span>python3Packages<span class="p">;</span> buildPythonPackage <span class="k">rec</span> <span class="p">{</span>
  <span class="ss">pname</span> <span class="o">=</span> <span class="s2">"isso"</span><span class="p">;</span>
  <span class="ss">version</span> <span class="o">=</span> <span class="s2">"custom"</span><span class="p">;</span>

  <span class="ss">src</span> <span class="o">=</span> pkgs<span class="o">.</span>fetchFromGitHub <span class="p">{</span>
    <span class="c1"># Use my fork</span>
    <span class="ss">owner</span> <span class="o">=</span> <span class="s2">"vincentbernat"</span><span class="p">;</span>
    <span class="ss">repo</span> <span class="o">=</span> pname<span class="p">;</span>
    <span class="ss">rev</span> <span class="o">=</span> <span class="s2">"vbe/master"</span><span class="p">;</span>
    <span class="ss">sha256</span> <span class="o">=</span> <span class="s2">"0vkkvjcvcjcdzdj73qig32hqgjly8n3ln2djzmhshc04i6g9z07j"</span><span class="p">;</span>
  <span class="p">};</span>

  <span class="ss">propagatedBuildInputs</span> <span class="o">=</span> <span class="p">[</span>
    itsdangerous
    jinja2
    misaka
    html5lib
    werkzeug
    bleach
    flask-caching
  <span class="p">];</span>

  <span class="ss">buildInputs</span> <span class="o">=</span> <span class="p">[</span>
    cffi
  <span class="p">];</span>

  <span class="ss">checkInputs</span> <span class="o">=</span> <span class="p">[</span> nose <span class="p">];</span>

  <span class="ss">checkPhase</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">    </span><span class="si">${</span>python<span class="o">.</span>interpreter<span class="si">}</span><span class="s s-Multiline"> setup.py nosetests</span>
<span class="s s-Multiline">  ''</span><span class="p">;</span>
<span class="p">};</span>
</code></pre></div>

<p>I want to run Isso through <a href="https://gunicorn.org/" title="Gunicorn 'Green Unicorn': a Python WSGI HTTP Server for UNIX">Gunicorn</a>. To this effect, I build a
Python environment combining Isso and Gunicorn. Then, I can invoke the
latter with <code>"${issoEnv}/bin/gunicorn"</code>, like with a <a href="https://docs.python.org/3/tutorial/venv.html" title="Python documentation: Virtual Environments and Packages">virtual
environment</a>.</p>
<div class="language-nix codehilite"><pre><span/><code><span class="ss">issoEnv</span> <span class="o">=</span> pkgs<span class="o">.</span>python3<span class="o">.</span>buildEnv<span class="o">.</span>override <span class="p">{</span>
    <span class="ss">extraLibs</span> <span class="o">=</span> <span class="p">[</span>
      issoPackage
      pkgs<span class="o">.</span>python3Packages<span class="o">.</span>gunicorn
      pkgs<span class="o">.</span>python3Packages<span class="o">.</span>gevent
    <span class="p">];</span>
<span class="p">};</span>
</code></pre></div>

<p>Before building a Docker image, I also need to specify the
configuration file for Isso:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="ss">issoConfig</span> <span class="o">=</span> pkgs<span class="o">.</span>writeText <span class="s2">"isso.conf"</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">  [general]</span>
<span class="s s-Multiline">  dbpath = /db/comments.db</span>
<span class="s s-Multiline">  host =</span>
<span class="s s-Multiline">    https://vincent.bernat.ch</span>
<span class="s s-Multiline">    http://localhost:8080</span>
<span class="s s-Multiline">  notify = smtp</span>
<span class="s s-Multiline">  […]</span>
<span class="s s-Multiline">''</span><span class="p">;</span>
</code></pre></div>

<p>NixOS comes with a <a href="https://nixos.org/manual/nixpkgs/stable/#ssec-pkgs-dockerTools-buildImage">convenient tool to build a Docker image</a> without
a <code>Dockerfile</code>:</p>
<div class="language-nix codehilite"><pre><span/><code><span class="ss">issoDockerImage</span> <span class="o">=</span> pkgs<span class="o">.</span>dockerTools<span class="o">.</span>buildImage <span class="p">{</span>
  <span class="ss">name</span> <span class="o">=</span> <span class="s2">"isso"</span><span class="p">;</span>
  <span class="ss">tag</span> <span class="o">=</span> <span class="s2">"latest"</span><span class="p">;</span>
  <span class="ss">extraCommands</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">    mkdir -p db</span>
<span class="s s-Multiline">  ''</span><span class="p">;</span>
  <span class="ss">config</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">Cmd</span> <span class="o">=</span> <span class="p">[</span> <span class="s2">"</span><span class="si">${</span>issoEnv<span class="si">}</span><span class="s2">/bin/gunicorn"</span>
            <span class="s2">"--name"</span> <span class="s2">"isso"</span>
            <span class="s2">"--bind"</span> <span class="s2">"0.0.0.0:</span><span class="si">${</span>port<span class="si">}</span><span class="s2">"</span>
            <span class="s2">"--worker-class"</span> <span class="s2">"gevent"</span>
            <span class="s2">"--workers"</span> <span class="s2">"2"</span>
            <span class="s2">"--worker-tmp-dir"</span> <span class="s2">"/dev/shm"</span>
            <span class="s2">"--preload"</span>
            <span class="s2">"isso.run"</span>
          <span class="p">];</span>
    <span class="ss">Env</span> <span class="o">=</span> <span class="p">[</span>
      <span class="s2">"ISSO_SETTINGS=</span><span class="si">${</span>issoConfig<span class="si">}</span><span class="s2">"</span>
      <span class="s2">"SSL_CERT_FILE=</span><span class="si">${</span>pkgs<span class="o">.</span>cacert<span class="si">}</span><span class="s2">/etc/ssl/certs/ca-bundle.crt"</span>
    <span class="p">];</span>
  <span class="p">};</span>
<span class="p">};</span>
</code></pre></div>

<p>Because we refer to the <code>issoEnv</code> derivation in <code>config.Cmd</code>, the
whole derivation, including Isso and Gunicorn, is copied inside the
Docker image. The same applies for <code>issoConfig</code>, the configuration
file we created earlier, and <code>pkgs.cacert</code>, the derivation containing
trusted root certificates. The resulting image is 171 MB once
installed, which is comparable to the Debian Buster image generated by
the official <a href="https://github.com/isso-comments/isso/blob/master/Dockerfile"><code>Dockerfile</code></a>.</p>
<p>NixOS features an abstraction to run Docker containers. It is not
currently documented in the NixOS manual, but you can look at the <a href="https://github.com/NixOS/nixpkgs/blob/release-20.09/nixos/modules/virtualisation/oci-containers.nix">source
code of the module</a> for the available options. I chose to use
<a href="https://podman.io/">Podman</a> instead of Docker as the backend because it does not
require running an additional daemon.</p>
<div class="language-nix codehilite"><pre><span/><code>virtualisation<span class="o">.</span><span class="ss">oci-containers</span> <span class="o">=</span> <span class="p">{</span>
  <span class="ss">backend</span> <span class="o">=</span> <span class="s2">"podman"</span><span class="p">;</span>
  <span class="ss">containers</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">isso</span> <span class="o">=</span> <span class="p">{</span>
      <span class="ss">image</span> <span class="o">=</span> <span class="s2">"isso"</span><span class="p">;</span>
      <span class="ss">imageFile</span> <span class="o">=</span> issoDockerImage<span class="p">;</span>
      <span class="ss">ports</span> <span class="o">=</span> <span class="p">[</span><span class="s2">"127.0.0.1:</span><span class="si">${</span>port<span class="si">}</span><span class="s2">:</span><span class="si">${</span>port<span class="si">}</span><span class="s2">"</span><span class="p">];</span>
      <span class="ss">volumes</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s2">"/var/db/isso:/db"</span>
      <span class="p">];</span>
    <span class="p">};</span>
  <span class="p">};</span>
<span class="p">};</span>
</code></pre></div>

<p>A systemd unit file is automatically created to run and supervise the container:</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>systemctl<span class="w"> </span>status<span class="w"> </span>podman-isso.service
<span class="go">● podman-isso.service</span>
<span class="go">     Loaded: loaded (/nix/store/a66gzqqwcdzbh99sz8zz5l5xl8r8ag7w-unit-&gt;</span>
<span class="go">     Active: active (running) since Sun 2020-11-01 16:04:16 UTC; 4min 44s ago</span>
<span class="go">    Process: 14564 ExecStartPre=/nix/store/95zfn4vg4867gzxz1gw7nxayqcl&gt;</span>
<span class="go">   Main PID: 14697 (podman)</span>
<span class="go">         IP: 0B in, 0B out</span>
<span class="go">      Tasks: 10 (limit: 2313)</span>
<span class="go">     Memory: 221.3M</span>
<span class="go">        CPU: 10.058s</span>
<span class="go">     CGroup: /system.slice/podman-isso.service</span>
<span class="go">             ├─14697 /nix/store/pn52xgn1wb2vr2kirq3xj8ij0rys35mf-podma&gt;</span>
<span class="go">             └─14802 /nix/store/7vsba54k6ag4cfsfp95rvjzqf6rab865-conmo&gt;</span>

<span class="go">nov. 01 16:04:17 web03 podman[14697]: container init (image=localhost/isso:latest)</span>
<span class="go">nov. 01 16:04:17 web03 podman[14697]: container start (image=localhost/isso:latest)</span>
<span class="go">nov. 01 16:04:17 web03 podman[14697]: container attach (image=localhost/isso:latest)</span>
<span class="go">nov. 01 16:04:19 web03 conmon[14802]: INFO: connected to SMTP server</span>
<span class="go">nov. 01 16:04:19 web03 conmon[14802]: INFO: connected to https://vincent.bernat.ch</span>
<span class="go">nov. 01 16:04:19 web03 conmon[14802]: [INFO] Starting gunicorn 20.0.4</span>
<span class="go">nov. 01 16:04:19 web03 conmon[14802]: [INFO] Listening at: http://0.0.0.0:8080 (1)</span>
<span class="go">nov. 01 16:04:19 web03 conmon[14802]: [INFO] Using worker: gevent</span>
<span class="go">nov. 01 16:04:19 web03 conmon[14802]: [INFO] Booting worker with pid: 8</span>
<span class="go">nov. 01 16:04:19 web03 conmon[14802]: [INFO] Booting worker with pid: 9</span>
</code></pre></div>

<p>As the last step, we configure Nginx to forward requests for
<code>comments.luffy.cx</code> to the container. NixOS provides a simple
integration to grab a <em>Let’s Encrypt</em> certificate.</p>
<div class="language-nix codehilite"><pre><span/><code>services<span class="o">.</span>nginx<span class="o">.</span>virtualHosts<span class="o">.</span><span class="s2">"comments.luffy.cx"</span> <span class="o">=</span> <span class="p">{</span>
  <span class="ss">root</span> <span class="o">=</span> <span class="s2">"/data/webserver/comments.luffy.cx"</span><span class="p">;</span>
  <span class="ss">enableACME</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
  <span class="ss">forceSSL</span> <span class="o">=</span> <span class="no">true</span><span class="p">;</span>
  <span class="ss">extraConfig</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">    access_log /var/log/nginx/comments.luffy.cx.log anonymous;</span>
<span class="s s-Multiline">  ''</span><span class="p">;</span>
  locations<span class="o">.</span><span class="s2">"/"</span> <span class="o">=</span> <span class="p">{</span>
    <span class="ss">proxyPass</span> <span class="o">=</span> <span class="s2">"http://127.0.0.1:</span><span class="si">${</span>port<span class="si">}</span><span class="s2">"</span><span class="p">;</span>
    <span class="ss">extraConfig</span> <span class="o">=</span> <span class="s s-Multiline">''</span>
<span class="s s-Multiline">      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;</span>
<span class="s s-Multiline">      proxy_set_header Host $host;</span>
<span class="s s-Multiline">      proxy_set_header X-Forwarded-Proto $scheme;</span>
<span class="s s-Multiline">      proxy_hide_header Set-Cookie;</span>
<span class="s s-Multiline">      proxy_hide_header X-Set-Cookie;</span>
<span class="s s-Multiline">      proxy_ignore_headers Set-Cookie;</span>
<span class="s s-Multiline">    ''</span><span class="p">;</span>
  <span class="p">};</span>
<span class="p">};</span>
security<span class="o">.</span>acme<span class="o">.</span>certs<span class="o">.</span><span class="s2">"comments.luffy.cx"</span> <span class="o">=</span> <span class="p">{</span>
  <span class="ss">email</span> <span class="o">=</span> lib<span class="o">.</span>concatStringsSep <span class="s2">"@"</span> <span class="p">[</span> <span class="s2">"letsencrypt"</span> <span class="s2">"vincent.bernat.ch"</span> <span class="p">];</span>
<span class="p">};</span>
</code></pre></div>

<hr/>
<p>While I still struggle with Nix and NixOS, I am convinced this is how
<strong>declarative infrastructure</strong> should be done. I like how in <a href="https://github.com/vincentbernat/nixops-take1/blob/master/tags/isso.nix">one
single file</a>, I can define the derivation to build Isso, the
configuration, the Docker image, the container definition, and the
Nginx configuration. The Nix language is used both for <strong>building
packages</strong> and for <strong>managing configurations</strong>.</p>
<p>Moreover, the Docker image is updated
automatically like a regular NixOS host. This solves an issue plaguing
the Docker ecosystem: no more stale images! My next step would be to
combine this approach with <a href="https://developer.hashicorp.com/nomad" title="Nomad: Workload Orchestration Made Easy">Nomad</a>, a simple orchestrator to deploy
and manage containers.</p>
<div class="admonition">
<p class="admonition-title">Update (2023-01)</p>
<p>I am currently not using a Docker image anymore, but a <a href="https://github.com/vincentbernat/nixops-take1/commit/04f01c8c991845e05054e1a58145eb262828eec4"><em>systemd</em>
container</a>. The <em>Nix</em> store is shared with the host and <em>Isso</em> runs
as a dynamic user.</p>
</div>
<div class="footnote">
<hr/>
<ol>
<li id="fn:alternative">
<p>There is a subtle difference: I am using
<code>buildPythonPackage</code> instead of <code>buildPythonApplication</code>. This is
important for the next step. I did not find a way to convert an
application to a package. <a class="footnote-backref" href="#fnref:alternative" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
</feed>