<?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 — Web</title>
  <link href="https://vincent.bernat.ch/en/blog/atom-web.xml" rel="self"/>
  <link href="https://vincent.bernat.ch/en/blog#tag-web" rel="alternate"/>
  <id>http://www.luffy.cx/en/blog/atom-web.xml/</id>
  <updated>2026-10-20T08:00:55Z</updated>

  <entry>
    <title type="html">Self-hosted HTTP tunnels with SSH and nginx</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/en/blog/2026-http-over-ssh" rel="alternate"/>
    <link href="https://vincent.bernat.ch/en/blog/2026-http-over-ssh#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-10-03T07:23:12Z</updated>
    <id>http://www.luffy.cx/en/blog/2026-http-over-ssh.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>A friend wants to proofread your work-in-progress blog post, but its preview
only runs on <code>localhost:8080</code>. <a href="https://github.com/anderspitman/awesome-tunneling" title="awesome-tunneling: a list of tunneling software and services">Several tools</a> can help. Some run as a
commercial service, like <a href="https://ngrok.com/" title="ngrok">ngrok</a> or <a href="https://developers.cloudflare.com/tunnel/get-started/quick-tunnels/" title="Quick Tunnels with TryCloudflare">Cloudflare Quick Tunnels</a>. Some are
self-hostable but require a specific client, like <a href="https://github.com/fatedier/frp" title="frp: a fast reverse proxy">frp</a> or <a href="https://github.com/localtunnel/localtunnel" title="localtunnel: expose your localhost to the world">localtunnel</a>.
Some only require a plain SSH client but rely on a specific SSH server, like
<a href="https://docs.ssi.sh/" title="sish: host public web services on localhost">sish</a>. Let’s implement a self-hosted solution with only <em>OpenSSH</em> and
<em>nginx</em>!</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>ssh<span class="w"> </span>-R<span class="w"> </span><span class="m">0</span>:localhost:8080<span class="w"> </span>http-over-ssh
<span class="go">Allocated port 41535 for remote forward to localhost:8080</span>
<span class="go">https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/</span>
</code></pre></div>

<h1 id="basic-setup">Basic setup</h1>
<p>First, we forward connections from a port on a remote server to your local
service:</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>ssh<span class="w"> </span>-N<span class="w"> </span>-R<span class="w"> </span><span class="m">0</span>:localhost:8080<span class="w"> </span>web02.luffy.cx
<span class="go">Allocated port 41535 for remote forward to localhost:8080</span>
</code></pre></div>

<p>When you specify <code>0</code> as the remote port, the server allocates a free port.
Then, we configure nginx to proxy requests from <code>https://p41535.ssh.luffy.cx</code> to
<code>http://127.0.0.1:41535</code>:</p>
<div class="language-nginx-configuration-file codehilite"><pre><span/><code><span class="k">server</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">listen</span><span class="w"> </span><span class="n">0.0.0.0</span><span class="p">:</span><span class="mi">443</span><span class="w"> </span><span class="s">ssl</span><span class="w"> </span><span class="p">;</span>
<span class="w">  </span><span class="kn">listen</span><span class="w"> </span><span class="s">[::0]:443</span><span class="w"> </span><span class="s">ssl</span><span class="w"> </span><span class="p">;</span>
<span class="w">  </span><span class="kn">server_name</span><span class="w"> </span><span class="p">~</span><span class="sr">^p(?&lt;port&gt;\d\d\d\d\d)\.ssh\.luffy\.cx$;</span>
<span class="w">  </span><span class="s">location</span><span class="w"> </span><span class="s">/</span><span class="w"> </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.1:</span><span class="nv">$port</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>We also need to add DNS records for <code>*.ssh.luffy.cx</code> and get a wildcard
certificate through <a href="https://letsencrypt.org/">Let’s Encrypt</a>:</p>
<div class="language-zone codehilite"><pre><span/><code><span class="n">*.ssh.luffy.cx.</span><span class="w">               </span><span class="kt">CNAME</span><span class="w"> </span><span class="l">web02.luffy.cx.</span>
<span class="n">ssh.luffy.cx.</span><span class="w">                 </span><span class="kt">CAA</span><span class="w">   </span><span class="mi">0</span><span class="w"> </span><span class="l">issuewild</span><span class="w"> </span><span class="s">"letsencrypt.org"</span>
<span class="n">_acme-challenge.ssh.luffy.cx</span><span class="w">  </span><span class="kt">CNAME</span><span class="w"> </span><span class="l">ssh.luffy.cx.acme.luffy.cx.</span>
</code></pre></div>

<p><code>acme.luffy.cx</code> is a zone hosted on Route 53. I use it for <a href="https://letsencrypt.org/docs/challenge-types/#dns-01-challenge" title="DNS-01 challenge">ACME DNS-01
challenges</a>, both for wildcard certificates and for domains served by
several web servers. In my case, NixOS <a href="https://wiki.nixos.org/wiki/ACME" title="ACME on the NixOS Wiki">gets the certificates
automatically</a>.</p>
<h1 id="access-control">Access control</h1>
<p>The port is the only “secret”<sup id="fnref:port"><a class="footnote-ref" href="#fn:port">1</a></sup> keeping the content confidential. Other
forwarding solutions add a random string to the domain name to prevent an
intruder from enumerating the possible values.</p>
<p>Thanks to <a href="https://nginx.org/en/docs/http/ngx_http_secure_link_module.html" title="Module ngx_http_secure_link_module for nginx"><code>ngx_http_secure_link_module</code></a>, we can secure
this setup a bit. This module computes a hash<sup id="fnref:md5"><a class="footnote-ref" href="#fn:md5">2</a></sup> over a set of values,
including a secret, and compares it with the hash from the request. The hash is
base64-encoded, so we cannot put it in the domain name, which is
case-insensitive. Instead, we put it in the URL as a username, along with its
expiration timestamp:<sup id="fnref:expiration"><a class="footnote-ref" href="#fn:expiration">3</a></sup></p>
<div class="language-text-only codehilite"><pre><span/><code>https://6J3jK1WmB15c6WmjW_X-Wg--1789928654@p41535.ssh.luffy.cx/en/blog
        ╰─────────┬──────────╯  ╰───┬────╯  ╰─┬─╯             ╰──┬───╯
                hash             expires    port               path
</code></pre></div>

<p>The client sends the username to the server with <a href="https://www.rfc-editor.org/rfc/rfc7617" title="RFC 7617: The 'Basic' HTTP Authentication Scheme">HTTP basic
authentication</a>. This works with most HTTP clients, including <code>curl</code>.
Nginx exposes the username in the <code>$remote_user</code> variable. The module expects
the hash and the expiration timestamp separated by a comma. We use a <code>map</code>
directive to extract the two parts from <code>$remote_user</code> and join them with a
comma.<sup id="fnref:comma"><a class="footnote-ref" href="#fn:comma">4</a></sup> We also give the module the string to hash. It contains the
expiration timestamp, the port, and a secret:</p>
<div class="language-nginx-configuration-file codehilite"><pre><span/><code><span class="k">map</span><span class="w"> </span><span class="nv">$remote_user</span><span class="w"> </span><span class="nv">$httpssh_link</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">"~^([-_A-Za-z0-9]{22})--([0-9]+)$"</span><span class="w"> </span><span class="s">"</span><span class="nv">$1,$2"</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">server</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="c1"># […]</span>
<span class="w">  </span><span class="kn">location</span><span class="w"> </span><span class="s">/</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kn">secure_link</span><span class="w"> </span><span class="nv">$httpssh_link</span><span class="p">;</span>
<span class="w">    </span><span class="kn">secure_link_md5</span><span class="w"> </span><span class="s">"</span><span class="nv">$secure_link_expires</span><span class="w"> </span><span class="nv">$port</span><span class="w"> </span><span class="s">ZuPerS3cr3!"</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>The module returns the status of the check in the <code>$secure_link</code> variable:</p>
<ul>
<li>empty if the hashes do not match,</li>
<li><code>"0"</code> if they match but the link has expired, or</li>
<li><code>"1"</code> otherwise.</li>
</ul>
<p>If the hash is incorrect or missing, we return a 401 error with a
<code>WWW-Authenticate</code> header to ask for credentials. If the link has expired, we
return a 410 error. We remove the <code>Authorization</code> header before forwarding the
request and add a few directives to <a href="https://nginx.org/en/docs/http/websocket.html" title="nginx documentation: WebSocket proxying">proxy WebSocket connections</a>.
Here is the complete configuration:<sup id="fnref:security"><a class="footnote-ref" href="#fn:security">5</a></sup></p>
<div class="language-nginx-configuration-file codehilite"><pre><span/><code><span class="k">map</span><span class="w"> </span><span class="nv">$remote_user</span><span class="w"> </span><span class="nv">$httpssh_link</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">"~^([-_A-Za-z0-9]{22})--([0-9]+)$"</span><span class="w"> </span><span class="s">"</span><span class="nv">$1,$2"</span><span class="p">;</span>
<span class="p">}</span>
<span class="k">server</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="kn">listen</span><span class="w"> </span><span class="n">0.0.0.0</span><span class="p">:</span><span class="mi">443</span><span class="w"> </span><span class="s">ssl</span><span class="w"> </span><span class="p">;</span>
<span class="w">  </span><span class="kn">listen</span><span class="w"> </span><span class="s">[::0]:443</span><span class="w"> </span><span class="s">ssl</span><span class="w"> </span><span class="p">;</span>
<span class="w">  </span><span class="kn">server_name</span><span class="w"> </span><span class="p">~</span><span class="sr">^p(?&lt;port&gt;\d\d\d\d\d)\.ssh\.luffy\.cx$;</span>
<span class="w">  </span><span class="s">location</span><span class="w"> </span><span class="s">/</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="kn">secure_link</span><span class="w"> </span><span class="nv">$httpssh_link</span><span class="p">;</span>
<span class="w">    </span><span class="kn">secure_link_md5</span><span class="w"> </span><span class="s">"</span><span class="nv">$secure_link_expires</span><span class="w"> </span><span class="nv">$port</span><span class="w"> </span><span class="s">ZuPerS3cr3!"</span><span class="p">;</span>
<span class="w">    </span><span class="kn">if</span><span class="w"> </span><span class="s">(</span><span class="nv">$secure_link</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"")</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="kn">add_header</span><span class="w"> </span><span class="s">WWW-Authenticate</span><span class="w"> </span><span class="s">'Basic</span><span class="w"> </span><span class="s">realm="tunnel"'</span><span class="w"> </span><span class="s">always</span><span class="p">;</span>
<span class="w">      </span><span class="kn">return</span><span class="w"> </span><span class="mi">401</span><span class="p">;</span>
<span class="w">    </span><span class="p">}</span>
<span class="w">    </span><span class="kn">if</span><span class="w"> </span><span class="s">(</span><span class="nv">$secure_link</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="s">"0")</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="kn">return</span><span class="w"> </span><span class="mi">410</span><span class="p">;</span>
<span class="w">    </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.1:</span><span class="nv">$port</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Host</span><span class="w"> </span><span class="nv">$host</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">X-Forwarded-For</span><span class="w"> </span><span class="nv">$proxy_add_x_forwarded_for</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Authorization</span><span class="w"> </span><span class="s">""</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_http_version</span><span class="w"> </span><span class="mi">1</span><span class="s">.1</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Upgrade</span><span class="w"> </span><span class="nv">$http_upgrade</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_set_header</span><span class="w"> </span><span class="s">Connection</span><span class="w"> </span><span class="s">"upgrade"</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_buffering</span><span class="w"> </span><span class="no">off</span><span class="p">;</span>
<span class="w">    </span><span class="kn">proxy_read_timeout</span><span class="w"> </span><span class="mi">30m</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>I think you are now asking yourself the obvious question: “How should I generate
the hash?” Easy peasy!</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span><span class="nv">expires</span><span class="o">=</span><span class="k">$((</span><span class="w"> </span><span class="k">$(</span>date<span class="w"> </span>+%s<span class="k">)</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="m">86400</span><span class="w"> </span><span class="k">))</span>
<span class="gp">$ </span><span class="nv">port</span><span class="o">=</span><span class="m">41535</span>
<span class="gp">$ </span><span class="nv">secret</span><span class="o">=</span><span class="s1">'ZuPerS3cr3!'</span>
<span class="gp">$ </span><span class="nb">printf</span><span class="w"> </span><span class="s1">'%s %s %s'</span><span class="w"> </span><span class="s2">"</span><span class="nv">$expires</span><span class="s2">"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$port</span><span class="s2">"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$secret</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="p">|</span><span class="w"> </span>openssl<span class="w"> </span>md5<span class="w"> </span>-binary<span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="p">|</span><span class="w"> </span>openssl<span class="w"> </span>base64<span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="p">|</span><span class="w"> </span>tr<span class="w"> </span>+/<span class="w"> </span>-_<span class="w"> </span><span class="p">|</span><span class="w"> </span>tr<span class="w"> </span>-d<span class="w"> </span><span class="o">=</span>
<span class="go">6J3jK1WmB15c6WmjW_X-Wg</span>
</code></pre></div>

<p>Well, I suppose you are now saying: “Vincent, this is not very convenient! I’ll stick
with ngrok if you don’t mind.” Okay, I hear you. Let’s write a helper script.</p>
<h1 id="helper-script">Helper script</h1>
<p>The main difficulty is finding the ephemeral port that OpenSSH allocates, as it
does not appear in any environment variable.<sup id="fnref:env"><a class="footnote-ref" href="#fn:env">6</a></sup> To work around this obstacle,
we look for the ancestor <code>sshd-session</code> processes:<sup id="fnref:sshd-session"><a class="footnote-ref" href="#fn:sshd-session">7</a></sup></p>
<div class="language-bash codehilite"><pre><span/><code><span class="nv">pids</span><span class="o">=</span><span class="k">$(</span>
<span class="w">  </span><span class="nv">pid</span><span class="o">=</span><span class="nv">$$</span>
<span class="w">  </span><span class="k">while</span><span class="w"> </span><span class="o">[</span><span class="w"> </span><span class="s2">"</span><span class="nv">$pid</span><span class="s2">"</span><span class="w"> </span>-gt<span class="w"> </span><span class="m">1</span><span class="w"> </span><span class="o">]</span><span class="p">;</span><span class="w"> </span><span class="k">do</span>
<span class="w">    </span><span class="nv">line</span><span class="o">=</span><span class="k">$(</span>ps<span class="w"> </span>-o<span class="w"> </span><span class="nv">comm</span><span class="o">=</span>,pid<span class="o">=</span>,ppid<span class="o">=</span><span class="w"> </span>-p<span class="w"> </span><span class="s2">"</span><span class="nv">$pid</span><span class="s2">"</span><span class="k">)</span>
<span class="w">    </span><span class="nb">echo</span><span class="w"> </span><span class="s2">"</span><span class="nv">$line</span><span class="s2">"</span>
<span class="w">    </span><span class="nv">pid</span><span class="o">=</span><span class="si">${</span><span class="nv">line</span><span class="p">##* </span><span class="si">}</span>
<span class="w">  </span><span class="k">done</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>awk<span class="w"> </span><span class="s1">'$1 == "sshd-session" { printf "pid=%s,\n", $2 }'</span>
<span class="k">)</span>
<span class="k">if</span><span class="w"> </span><span class="o">[</span><span class="w"> </span>-z<span class="w"> </span><span class="s2">"</span><span class="nv">$pids</span><span class="s2">"</span><span class="w"> </span><span class="o">]</span><span class="p">;</span><span class="w"> </span><span class="k">then</span>
<span class="w">  </span><span class="nb">echo</span><span class="w"> </span><span class="s2">"not an ssh session"</span><span class="w"> </span>&gt;<span class="p">&amp;</span><span class="m">2</span>
<span class="w">  </span><span class="nb">exit</span><span class="w"> </span><span class="m">1</span>
<span class="k">fi</span>
</code></pre></div>

<p>Then, we get the listening ports associated with these <code>sshd-session</code>
processes:<sup id="fnref:sudo"><a class="footnote-ref" href="#fn:sudo">8</a></sup></p>
<div class="language-bash codehilite"><pre><span/><code><span class="nv">ports</span><span class="o">=</span><span class="k">$(</span>sudo<span class="w"> </span>-n<span class="w"> </span>ss<span class="w"> </span>--listening<span class="w"> </span>--numeric<span class="w"> </span>--tcp<span class="w"> </span>--processes<span class="w"> </span>--no-header<span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>grep<span class="w"> </span>-F<span class="w"> </span><span class="s2">"</span><span class="nv">$pids</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>awk<span class="w"> </span><span class="s1">'{ print $4 }'</span><span class="w"> </span><span class="p">|</span><span class="w"> </span>awk<span class="w"> </span>-F:<span class="w"> </span><span class="s1">'{ print $NF }'</span><span class="w"> </span><span class="se">\</span>
<span class="w">  </span><span class="p">|</span><span class="w"> </span>sort<span class="w"> </span>-un<span class="k">)</span>
<span class="k">if</span><span class="w"> </span><span class="o">[</span><span class="w"> </span>-z<span class="w"> </span><span class="s2">"</span><span class="nv">$ports</span><span class="s2">"</span><span class="w"> </span><span class="o">]</span><span class="p">;</span><span class="w"> </span><span class="k">then</span>
<span class="w">  </span><span class="nb">echo</span><span class="w"> </span><span class="s2">"no forwarded port, use ssh -R 0:localhost:PORT"</span><span class="w"> </span>&gt;<span class="p">&amp;</span><span class="m">2</span>
<span class="w">  </span><span class="nb">exit</span><span class="w"> </span><span class="m">1</span>
<span class="k">fi</span>
</code></pre></div>

<p>Finally, we display the URLs and keep the session open:</p>
<div class="language-bash codehilite"><pre><span/><code><span class="nv">lifetime</span><span class="o">=</span><span class="m">86400</span>
<span class="nv">secret</span><span class="o">=</span><span class="s1">'ZuPerS3cr3!'</span>
<span class="nv">expires</span><span class="o">=</span><span class="k">$((</span><span class="w"> </span><span class="k">$(</span>date<span class="w"> </span>+%s<span class="k">)</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="nv">lifetime</span><span class="w"> </span><span class="k">))</span>
<span class="k">for</span><span class="w"> </span>port<span class="w"> </span><span class="k">in</span><span class="w"> </span><span class="nv">$ports</span><span class="p">;</span><span class="w"> </span><span class="k">do</span>
<span class="w">  </span><span class="nv">token</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span><span class="w"> </span><span class="s1">'%s %s %s'</span><span class="w"> </span><span class="s2">"</span><span class="nv">$expires</span><span class="s2">"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$port</span><span class="s2">"</span><span class="w"> </span><span class="s2">"</span><span class="nv">$secret</span><span class="s2">"</span><span class="w"> </span><span class="se">\</span>
<span class="w">            </span><span class="p">|</span><span class="w"> </span>openssl<span class="w"> </span>md5<span class="w"> </span>-binary<span class="w"> </span><span class="se">\</span>
<span class="w">            </span><span class="p">|</span><span class="w"> </span>openssl<span class="w"> </span>base64<span class="w"> </span><span class="se">\</span>
<span class="w">            </span><span class="p">|</span><span class="w"> </span>tr<span class="w"> </span>+/<span class="w"> </span>-_<span class="w"> </span><span class="p">|</span><span class="w"> </span>tr<span class="w"> </span>-d<span class="w"> </span><span class="o">=</span><span class="k">)</span>
<span class="w">  </span><span class="nb">echo</span><span class="w"> </span><span class="s2">"https://</span><span class="nv">$token</span><span class="s2">--</span><span class="nv">$expires</span><span class="s2">@p</span><span class="nv">$port</span><span class="s2">.ssh.luffy.cx/"</span>
<span class="k">done</span>
sleep<span class="w"> </span>infinity
</code></pre></div>

<p>I install this script as <code>http-over-ssh</code> on the server and add this entry to my
<code>~/.ssh/config</code>:</p>
<div class="language-ssh-config codehilite"><pre><span/><code><span class="k">Host</span><span class="w"> </span>http-over-ssh
<span class="w">  </span><span class="na">Hostname</span><span class="w"> </span>web02.luffy.cx
<span class="w">  </span><span class="na">RemoteCommand</span><span class="w"> </span>http-over-ssh
<span class="w">  </span><span class="na">ControlPath</span><span class="w"> </span>none
</code></pre></div>

<p>With this solution, I only rely on OpenSSH and nginx, two pieces of software
already running on this server. One short command gives me a self-hosted tunnel
and a URL to share. To try it, grab the <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/tags/http-over-ssh.sh" title="nixops-take1/tags/http-over-ssh.sh">complete helper script</a>, which
includes a few minor improvements. If you run NixOS, as any person of taste
would, have a look at my <a href="https://github.com/vincentbernat/nixops-take1/blob/352cb26a581418d7c50f8377f8dc78016565470c/tags/http-over-ssh.nix" title="nixops-take1/tags/http-over-ssh.nix"><code>http-over-ssh.nix</code></a> instead. ❄️</p>
<div class="admonition">
<p class="admonition-title">Update (2026-10)</p>
<p>This post was widely discussed on <a href="https://news.ycombinator.com/item?id=49958569">Hacker News</a>.</p>
</div>
<div class="admonition">
<p class="admonition-title">Update (2026-10)</p>
<p>I have since switched to Caddy, which does not include a
module to check the validity of a URL. Instead, Caddy queries a <a href="https://github.com/vincentbernat/nixops-take1/blob/master/packages/http-over-ssh-auth/main.go" title="nixops-take1/packages/http-over-ssh-auth/main.go at master">local HTTP
service written in Go</a> through the <code>forward_auth</code> directive. As a bonus,
this lets me put the token in the domain name.</p>
</div>
<div class="footnote">
<hr/>
<ol>
<li id="fn:port">
<p>Since the kernel allocates it from the local port range, its entropy is low.
Moreover, on Linux, we lose one more bit because the kernel <a href="https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/net/ipv4/inet_connection_sock.c?h=v7.2#n349" title="net/ipv4/inet_connection_sock.c">favors odd
ports</a> when it picks a random free port.</p>
<div class="language-bash-session codehilite"><pre><span/><code><span class="gp">$ </span>sysctl<span class="w"> </span>-n<span class="w"> </span>net.ipv4.ip_local_port_range<span class="w"> </span><span class="se">\</span>
<span class="gp">&gt; </span><span class="w">  </span><span class="p">|</span><span class="w"> </span>awk<span class="w"> </span><span class="s1">'{print $1"—"$2" ≈ "log($2-$1+1)/log(2)" bits"}'</span>
<span class="go">32768—60999 ≈ 14.785 bits</span>
</code></pre></div>

<p><a class="footnote-backref" href="#fnref:port" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:md5">
<p>The module relies on MD5. Its security is weak but good enough for this
use. <a class="footnote-backref" href="#fnref:md5" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:expiration">
<p>We need an expiration date because a later session could reuse the port,
and we have no way to detect this case. <a class="footnote-backref" href="#fnref:expiration" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:comma">
<p>The username could use a comma directly instead of a double dash. But some
applications do not recognize the URL correctly, making it harder to share. <a class="footnote-backref" href="#fnref:comma" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:security">
<p>This configuration exposes any TCP port listening on <code>127.0.0.1</code> or
<code>0.0.0.0</code> to anyone with the secret, bypassing most firewall rules. You
could strengthen it by limiting the <code>server_name</code> regex to the ephemeral
port range. <a class="footnote-backref" href="#fnref:security" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
<li id="fn:env">
<p>One SSH session can contain several tunnels, and the client can add or
remove them at any time. This is unlike tun device forwarding (<code>ssh -w</code>),
which gets its own <code>SSH_TUNNEL</code> environment variable. <a class="footnote-backref" href="#fnref:env" title="Jump back to footnote 6 in the text">↩</a></p>
</li>
<li id="fn:sshd-session">
<p>Since OpenSSH 9.8, <code>sshd-session</code> is the name of the helper handling the
session. With older versions, look for <code>sshd</code> instead. <a class="footnote-backref" href="#fnref:sshd-session" title="Jump back to footnote 7 in the text">↩</a></p>
</li>
<li id="fn:sudo">
<p>We need <code>sudo</code> because the <code>sshd-session</code> process dropped its privileges.
The kernel then marks it as not dumpable, and its <a href="https://manpages.debian.org/proc_pid.5.html" title="Manual page for proc_pid(5)"><code>/proc/PID/fd</code> directory
belongs to root</a>. Without access to this directory, <code>ss</code> cannot
find which process owns a socket. <a class="footnote-backref" href="#fnref:sudo" title="Jump back to footnote 8 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <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">Sidenotes with CSS anchor positioning</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/en/blog/2026-css-sidenotes" rel="alternate"/>
    <link href="https://vincent.bernat.ch/en/blog/2026-css-sidenotes#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-09-07T06:49:58Z</updated>
    <id>http://www.luffy.cx/en/blog/2026-css-sidenotes.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>I am a heavy user of sidenotes:<sup id="fnref:phd"><a class="footnote-ref" href="#fn:phd">1</a></sup> they keep optional content next to the
text instead of sending the reader to the bottom of the page and back. <a href="https://edwardtufte.github.io/tufte-css/" title="Tufte CSS">Tufte
CSS</a> renders them without JavaScript but only accepts inline content. <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Anchor_positioning" title="CSS anchor positioning">CSS
anchor positioning</a>, now supported by recent browsers,<sup id="fnref:anchor-support"><a class="footnote-ref" href="#fn:anchor-support">2</a></sup> is an
elegant alternative. Sidenotes can hold several blocks, still without
JavaScript, and fall back below the paragraph referencing them on narrow
viewports and older browsers.</p>
<p>In 2023, Eric Meyer demonstrated this technique in “<a href="https://meyerweb.com/eric/thoughts/2023/09/12/nuclear-anchored-sidenotes/" title="Nuclear Anchored Sidenotes">Nuclear Anchored
Sidenotes</a>.” The main improvement over other solutions is that the notes can
sit anywhere in the HTML document. You can place them after the paragraph
referencing them, as regular block elements for text browsers, screen readers,
feed readers, and reader mode to render them properly:</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 465px"><span class="lf-media-inner" style="padding-bottom: 129.032%"><img alt="Sidenotes rendered in Lynx appear after the paragraph they are called&#10;from." src="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/lynx@1x.fbdd281f455d28.png" srcset="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/lynx@1x.fbdd281f455d28.png 585w,https://media-bernat-ch.b-cdn.net/images/css-sidenotes/lynx@2x.358853d7892aaf.png 930w" sizes="auto, (max-width: 465px) 100vw, 465px" width="465" height="600" class="lf-media"/></span></div><figcaption>Rendering in Lynx, a text browser</figcaption></figure>
<p>When the viewport is too narrow or the browser does not support CSS anchor
positioning, you can style them so the reader can skip them or glance at them
without losing their position in the text:</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 375px"><span class="lf-media-inner" style="padding-bottom: 177.867%"><img alt="Sidenotes rendered on a narrow viewport appear with a distinctive typography&#10;after the paragraph they are called&#10;from." src="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/narrow@1x.e343d33285dbf3.png" srcset="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/narrow@1x.e343d33285dbf3.png 471w,https://media-bernat-ch.b-cdn.net/images/css-sidenotes/narrow@2x.29fc379ed9b688.png 750w" sizes="auto, (max-width: 375px) 100vw, 375px" width="375" height="667" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAXcAAAKbAQMAAAA0YFjZAAAAA1BMVEX++fBg48voAAAANklEQVR42u3BgQAAAADDoPlTX+AIVQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8A30QAAGQR2y6AAAAAElFTkSuQmCC)"/></span></div><figcaption>Rendering below the paragraph on a narrow viewport</figcaption></figure>
<p>Once the viewport is large enough, they appear in the margin, at the same
vertical position as the matching reference mark, unless they would collide with
a previous sidenote, as in the example below:<sup id="fnref:runt"><a class="footnote-ref" href="#fn:runt">3</a></sup></p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 1427px"><span class="lf-media-inner" style="padding-bottom: 39.944%"><img alt="Sidenotes rendered on a large viewport appear in the margin. There are two of&#10;them. The first one is vertically aligned with the matching reference mark,&#10;while the second is rendered below as it would collide with the first&#10;otherwise." src="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/large@1x.8745117fd4dbc8.png" srcset="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/large@1x.8745117fd4dbc8.png 1794w,https://media-bernat-ch.b-cdn.net/images/css-sidenotes/large@2x.63cdd3f7a8d04d.png 2854w" sizes="auto, (max-width: 972px) 100vw, 900px" width="1427" height="570" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABZMAAAI6AQMAAABo8EQbAAAAA1BMVEX++O/08PdcAAAAeklEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgykNcAAfcP/P8AAAAASUVORK5CYII=)"/></span></div><figcaption>Rendering in the margin on a large viewport</figcaption></figure>
<p>The gist of CSS anchoring is to position an element relative to another
element—the anchor. For the sidenotes, the anchor is the reference mark. I use
the following markup, with a data attribute to specify the anchor name:</p>
<div class="language-html codehilite"><pre><span/><code><span class="p">&lt;</span><span class="nt">sup</span> <span class="na">id</span><span class="o">=</span><span class="s">"fnref:YYY"</span> <span class="na">data-anchor</span><span class="o">=</span><span class="s">"--lf-sn-YYY"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">"#sidenote-YYY"</span><span class="p">&gt;</span>1<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">sup</span><span class="p">&gt;</span>
</code></pre></div>

<p>The matching note is an <code>&lt;aside&gt;</code> element carrying the same data attribute for
the anchor name. We put it after the paragraph holding the reference mark:</p>
<div class="language-html codehilite"><pre><span/><code><span class="p">&lt;</span><span class="nt">aside</span> <span class="na">role</span><span class="o">=</span><span class="s">"note"</span> <span class="na">id</span><span class="o">=</span><span class="s">"sidenote-YYY"</span> <span class="na">data-anchor</span><span class="o">=</span><span class="s">"--lf-sn-YYY"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">sup</span><span class="p">&gt;</span>1<span class="p">&lt;/</span><span class="nt">sup</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>A first paragraph.<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;</span>A second paragraph.<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">aside</span><span class="p">&gt;</span>
</code></pre></div>

<p>On a narrow viewport or when the browser is too old for CSS anchoring, we style
the sidenote, which stays below its paragraph, with a muted color:</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">aside</span><span class="o">[</span><span class="nt">role</span><span class="o">=</span><span class="s2">"note"</span><span class="o">]</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">margin-block</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="w">  </span><span class="k">color</span><span class="p">:</span><span class="w"> </span><span class="mh">#444</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>On a wide viewport and when the browser is recent enough, we move the sidenote
to the right margin:</p>
<div class="language-css codehilite"><pre><span/><code><span class="p">@</span><span class="k">supports</span><span class="w"> </span><span class="o">(</span><span class="nt">anchor-name</span><span class="o">:</span><span class="w"> </span><span class="nt">attr</span><span class="o">(</span><span class="nt">data-anchor</span><span class="w"> </span><span class="nt">type</span><span class="o">(&lt;</span><span class="nt">custom-ident</span><span class="o">&gt;)))</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="p">@</span><span class="k">media</span><span class="w"> </span><span class="o">(</span><span class="nt">min-width</span><span class="o">:</span><span class="w"> </span><span class="nt">72rem</span><span class="o">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="nt">main</span><span class="w"> </span><span class="p">{</span>
<span class="w">      </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">relative</span><span class="p">;</span>
<span class="w">      </span><span class="nt">sup</span><span class="o">[</span><span class="nt">data-anchor</span><span class="o">]</span><span class="w"> </span><span class="p">{</span>
<span class="hll"><span class="w">        </span><span class="k">anchor-name</span><span class="p">:</span><span class="w"> </span><span class="nb">attr</span><span class="p">(</span><span class="n">data-anchor</span><span class="w"> </span><span class="nf">type</span><span class="p">(</span><span class="o">&lt;</span><span class="n">custom-ident</span><span class="o">&gt;</span><span class="p">));</span>
</span><span class="w">        </span><span class="c">/* → anchor-name: --lf-sn-YYY */</span>
<span class="w">      </span><span class="p">}</span>
<span class="w">      </span><span class="nt">aside</span><span class="o">[</span><span class="nt">role</span><span class="o">=</span><span class="s2">"note"</span><span class="o">][</span><span class="nt">data-anchor</span><span class="o">]</span><span class="w"> </span><span class="p">{</span>
<span class="hll"><span class="w">        </span><span class="k">anchor-name</span><span class="p">:</span><span class="w"> </span><span class="nv">--lf-sidenote</span><span class="p">;</span>
</span><span class="w">        </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">absolute</span><span class="p">;</span>
<span class="hll"><span class="w">        </span><span class="k">position-anchor</span><span class="p">:</span><span class="w"> </span><span class="nb">attr</span><span class="p">(</span><span class="n">data-anchor</span><span class="w"> </span><span class="nf">type</span><span class="p">(</span><span class="o">&lt;</span><span class="n">custom-ident</span><span class="o">&gt;</span><span class="p">));</span>
</span><span class="w">        </span><span class="c">/* → position-anchor: --lf-sn-YYY */</span>
<span class="hll"><span class="w">        </span><span class="k">top</span><span class="p">:</span><span class="w"> </span><span class="nf">max</span><span class="p">(</span><span class="nf">anchor</span><span class="p">(</span><span class="kc">top</span><span class="p">),</span><span class="w"> </span><span class="nf">anchor</span><span class="p">(</span><span class="nv">--lf-sidenote</span><span class="w"> </span><span class="kc">bottom</span><span class="p">,</span><span class="w"> </span><span class="mi">-1</span><span class="kt">rlh</span><span class="p">)</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">);</span>
</span><span class="w">        </span><span class="k">left</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="kt">%</span><span class="p">;</span>
<span class="w">        </span><span class="k">margin</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="mi">2</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">        </span><span class="k">width</span><span class="p">:</span><span class="w"> </span><span class="mi">18</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">        </span><span class="k">color</span><span class="p">:</span><span class="w"> </span><span class="kc">inherit</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="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p><code>attr()</code> extracts the anchor name for the reference mark from the <code>data-anchor</code>
attribute. It returns a string, unless we specify a CSS unit or a type, like
here: the browser parses the data attribute as a <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/custom-ident" title="&lt;custom-ident&gt; on MDN">custom
identifier</a>, which <code>anchor-name</code> validates as a <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/dashed-ident" title="&lt;dashed-ident&gt; on MDN">dashed
identifier</a>, a custom identifier starting with two
dashes.<sup id="fnref:attr-support"><a class="footnote-ref" href="#fn:attr-support">4</a></sup></p>
<p>The note itself is absolutely positioned past the right edge of the main block.
It selects the matching reference mark as its anchor with <code>position-anchor</code> set
to the value of the <code>data-anchor</code> attribute. Each note is also an anchor named
<code>--lf-sidenote</code>. We use it to keep the next note from colliding with this one.</p>
<p>The <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/anchor" title="anchor() CSS function on MDN"><code>anchor()</code> CSS function</a> lets us position the note’s top
edge relative to its anchor: <code>anchor(top)</code> aligns the top edge of the note with
the top edge of the reference mark. It can also take another anchor as a
parameter: <code>anchor(--lf-sidenote bottom)</code> would align the top edge of the note
with the bottom edge of the closest preceding anchor named <code>--lf-sidenote</code>—so
the previous note.<sup id="fnref:reference"><a class="footnote-ref" href="#fn:reference">5</a></sup> Like <code>attr()</code>, <code>anchor()</code> accepts a fallback
value as its second parameter and use it when the named anchor does not exist.</p>
<p>The <code>top</code> property handles three cases, illustrated in the following diagram:</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 913px"><span class="lf-media-inner" style="padding-bottom: 72.289%"><img alt="Diagram of three sidenotes anchored to their reference marks. The first one is&#10;aligned with the top of its own reference mark, as no note comes before it. The&#10;second one would overlap the first, so it takes the bottom of the first note as&#10;anchor and sits one line below it. The third one comes far enough down the page&#10;to align with its own reference mark&#10;again." src="https://media-bernat-ch.b-cdn.net/images/css-sidenotes/anchors.2e7e20fe1f5450.svg" width="913" height="660" class="lf-media"/></span></div><figcaption>The three cases for the vertical position of a note</figcaption></figure>
<ol>
<li>The first note’s top edge aligns with the top edge of its reference mark: as
   there is no previous note, <code>anchor(--lf-sidenote bottom, -1rlh) + 1rlh</code>
   resolves to 0 and <code>max()</code> returns <code>anchor(top)</code>.</li>
<li>When the reference mark of a later note sits above the bottom of the previous
   note, plus some vertical space, the note goes below the previous one to avoid
   a collision. <code>max()</code> returns <code>anchor(--lf-sidenote bottom) + 1rlh</code>.</li>
<li>Otherwise, the note’s top edge aligns with the reference mark’s top edge, as
   <code>max()</code> returns <code>anchor(top)</code>.</li>
</ol>
<hr/>
<p>Have a look at the <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/main/content/media/css/luffy1.sidenotes.css" title="Stylesheet for the sidenotes of this blog">complete stylesheet</a>, which also adapts the
reference mark to the location of the note: a “↓” arrow when the note sits below
the paragraph, a “→” arrow when it moves to the margin. Gwern’s “<a href="https://gwern.net/sidenote" title="Sidenotes In Web Design">Sidenotes In
Web Design</a>” lists more implementations and their trade-offs.</p>
<p><a href="https://danluu.com/bug-blind/" title="Bug blindness">Some bloggers</a> aim to write a post in 30 minutes. I planned to publish
three web-related articles this weekend. Instead, I spent an inordinate amount
of time elsewhere: about 15 commits on the build system, a pull request to
<a href="https://github.com/pygments/pygments/pull/3294" title="CSS: support nested rule">update CSS highlighting for nested selectors in Pygments</a>, and a
small correction to MDN’s article on the <a href="https://github.com/mdn/content/pull/45532" title="Fix wording for return value of anchor() function"><code>anchor()</code> CSS function</a>. The
SVG illustration took a bit less than an hour and the article itself a handful
of hours. The <code>attr()</code> function came in after I thought “inline style looks
ugly, isn’t there a better way?” But, hey, I still think this is worth it! 🎨</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:phd">
<p>My PhD advisor told me this is unwise. <a class="footnote-backref" href="#fnref:phd" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:anchor-support">
<p>The first bits of anchor positioning are <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/anchor-name#browser_compatibility" title="Browser compatibility for anchor-name on MDN">supported</a> from Chrome 125 (May 2024), Firefox 147 (January 2026), and Safari 26 (September 2025).</p>
<p>Before Safari 26.5, sidenotes may collide due to a <a href="https://bugs.webkit.org/show_bug.cgi?id=315704" title="[anchor-position-1] anchor referencing an anchor of the same name does not resolve to the previous one.">bug</a> in how dependency
chains are handled. You can <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/1e6299da5c95dfce6549dfb20da2cea7c9e58664/content/media/js/luffy1.sidenotes.js">detect this situation with some JavaScript</a>.
It is, however, not needed in the solution described here as we depend on a
more recent feature. <a class="footnote-backref" href="#fnref:anchor-support" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:runt">
<p>If you noticed the runt in the first note, I share your pain and lament that
Firefox does not implement <code>text-wrap: pretty</code>. <a class="footnote-backref" href="#fnref:runt" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:attr-support">
<p>Typed <code>attr()</code> is <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Values/attr#browser_compatibility" title="Browser compatibility for attr() on MDN">supported</a> from Chrome 133 (February 2025),
Firefox 155 (September 2026), and Safari 27 (September 2026). Check <a href="https://una.im/advanced-attr" title="New capabilities for attr()">Una
Kravets’ article</a> for details. To support more
browsers, you can inline the anchor name and the position anchor directly in
the HTML:</p>
<div class="language-html codehilite"><pre><span/><code><span class="p">&lt;</span><span class="nt">sup</span> <span class="na">id</span><span class="o">=</span><span class="s">"…"</span> <span class="na">style</span><span class="o">=</span><span class="s">"anchor-name: --lf-sn-…"</span><span class="p">&gt;</span>
  <span class="p">&lt;</span><span class="nt">a</span> <span class="na">href</span><span class="o">=</span><span class="s">"#sidenote-…"</span><span class="p">&gt;</span>1<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>
<span class="p">&lt;/</span><span class="nt">sup</span><span class="p">&gt;</span>
</code></pre></div>

<p>“<a href="https://blog.master.dev/managing-anchor-associations-with-data-attributes-and-advanced-attr/" title="Managing Anchor Associations With Data Attributes and Advanced attr()">Managing Anchor Associations With Data Attributes and Advanced
<code>attr()</code></a>,” by Daniel Schwarz, explores CSS
anchors and typed <code>attr()</code> in more detail. <a class="footnote-backref" href="#fnref:attr-support" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:reference">
<p>The exact rule for the <a href="https://drafts.csswg.org/css-anchor-position-1/#target-anchor-element" title="CSS Anchor Positioning: target anchor element">target anchor element</a> is more complex: “if an
ancestor of [the note] satisfies the following conditions, return the
nearest such element to [the note]. Otherwise, return the last element in
tree order that satisfies the conditions.” One of these conditions is that
“[the candidate] is an <a href="https://drafts.csswg.org/css-anchor-position-1/#acceptable-anchor-element" title="CSS Anchor Positioning: acceptable anchor element">acceptable anchor element</a> for [the note],” which
requires that “[the candidate] is laid out strictly before [the note],”
where the relevant clause is that “[the candidate] is either not absolutely
positioned or occurs earlier in the flat tree order than [the note].” <a class="footnote-backref" href="#fnref:reference" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">Blogging with LLMs as a non-native speaker</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/en/blog/2026-blogging-llm" rel="alternate"/>
    <link href="https://vincent.bernat.ch/en/blog/2026-blogging-llm#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-06-09T20:15:13Z</updated>
    <id>http://www.luffy.cx/en/blog/2026-blogging-llm.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p><abbr title="Artificial Intelligence">AI</abbr> slop is invading the web. A recent story about <a href="https://lobste.rs/s/29pm2f/llm_generated_submissions_should_be" title="LLM generated submissions should be disallowed">disallowing <abbr title="Large Language Model">LLM</abbr>-generated
submissions</a> on <a href="https://lobste.rs/" title="Lobsters">Lobsters</a> triggered a lot of debate. My personal worst
offenders are <a href="https://www.linkedin.com/">LinkedIn</a> articles with <abbr title="Artificial Intelligence">AI</abbr>-generated images and uninspired
articles filled with emojis from people trying to masquerade as experts on a
subject they don’t care enough to write themselves. While I am unhappy about
this situation, I rely on <abbr title="Large Language Models">LLMs</abbr> for <strong>grammar</strong>, <strong>copyediting</strong>, and
<strong>translation</strong>. I don’t see this as a contradiction.</p>
<p>I am a native French speaker, but I blog in both English and French. When I
started writing this blog in 2011, I was composing in <a href="/fr/blog/2011-migration-vers-github" title="Migration de Trac vers GitHub">French</a> and translating
to <a href="/en/blog/2011-migrating-to-github" title="Migrating from Trac to GitHub">English</a>, but I found it was <a href="https://sci-hub.fr/10.1016/j.jslw.2009.06.003" title="L1 use during L2 writing: An empirical study of a complex phenomenon">better to work in the reverse order</a> to
avoid unnatural and non-idiomatic constructions. One of my goals is to write
“good” English but I never felt it was my strong point.<sup id="fnref:book"><a class="footnote-ref" href="#fn:book">1</a></sup> For example, verb
tenses are often an issue, even if I mostly stick with the present tense. I
learn the rules and forget them right away. I also don’t feel like <a href="https://mtlynch.io/editor/" title="How I Hired a Freelance Editor for My Blog">hiring an
editor</a> for something I see as a hobby.</p>
<p>As an example, I have kept the history of the successive iterations when writing
“<a href="/en/blog/2026-akvorado-rib-sharding" title="Scaling Akvorado BMP RIB with sharding">Scaling Akvorado BMP RIB with sharding</a>”:</p>
<ol>
<li>the <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/aad263c20c7b021b1069952d1487374ff559d3b3" title="article: Akvorado BMP RIB with sharding">first draft</a>, authored with the help of a thesaurus,<sup id="fnref:kagi"><a class="footnote-ref" href="#fn:kagi">2</a></sup></li>
<li>the <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/11231af4be15160c1dd48e45c9b4dc7c042cf191" title="content: copyediting of Akvorado BMP RIB article">edited copy</a> revised by the <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/main/.claude/skills/edit/SKILL.md">copyediting skill</a>,</li>
<li>the <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/0435ae3a8450132abb89507e856a012831457e49" title="article: Akvorado BMP RIB in French">translation to French</a> generated with the <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/main/.claude/skills/translate/SKILL.md">translation
   skill</a>, and</li>
<li>the <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/c0629b1d9fe450335fcd30c071fb44301615dd15" title="content: human proofread of the French translation">human proofread of the French translation</a>, with minor
   edits to the English version.</li>
</ol>
<p>I know that <abbr title="Large Language Models">LLMs</abbr> may <a href="https://sites.google.com/view/llmwritingdistortion/home" title="How LLMs Distort Our Written Language">alter the author’s voice when editing</a>, but the
corrections in the second step are minor. The prompt asks to “apply light
stylistic edits,” with some guidance around avoiding passive voice, long
sentences, bland verbs, and filler words. It also defines the target audience:
technical with a B2 level in English.</p>
<p>In the following excerpt, I used “long time” instead of “long-standing.” The
former is missing a hyphen and applies to people—a <a href="https://dictionary.cambridge.org/us/dictionary/english/long-time" title="Definition of long-time in the Cambridge Dictionary">long-time</a> friend, while
the later relates to a situation—a <a href="https://dictionary.cambridge.org/us/dictionary/english/long-standing" title="Definition of long-standing in the Cambridge Dictionary">long-standing</a> agreement. I had a hard
time understanding the reason of the second change: the <abbr title="Large Language Model">LLM</abbr> prefers a
<a href="https://dictionary.cambridge.org/us/grammar/british-grammar/relative-clauses-defining-and-non-defining" title="Relative clauses: defining and non-defining">defining relative clause</a> to provide the definition of “RIB sharding.”</p>
<blockquote>
<p>As the Internet routing table contains more than 1 million routes, Akvorado
needs to scale to tens of millions of routes. This has been a <del>long
time</del> <ins>long-standing</ins> challenge, but I expect this issue is now
fixed by using RIB sharding, a method <del>to split</del> <ins>that
splits</ins> the routing database into several parts to enable concurrent
updates.</p>
</blockquote>
<p>In the next modification, the <abbr title="Large Language Model">LLM</abbr> puts “device” instead of “equipment.” This is
correct as “<a href="https://dictionary.cambridge.org/us/dictionary/english/equipment" title="Definition of equipment in the Cambridge Dictionary">equipment</a>” is an uncountable noun. I know that, but I still fall
into this trap.</p>
<blockquote>
<p>When Akvorado does not find a route from a specific device, it falls back to a
route sent by another <del>equipment</del> <ins>device</ins>.</p>
</blockquote>
<p>I ask the <abbr title="Large Language Model">LLM</abbr> to use “descriptive verbs” and it complies by replacing a
multi-word predicate with a lexically rich verb:</p>
<blockquote>
<p>The benchmarks demonstrate it <del>has better performance than</del>
<ins>outperforms</ins> other <del>packages, both</del> <ins>packages</ins> for
lookups, insertions, and memory usage.</p>
</blockquote>
<p>It also fixes grammar errors. In the next excerpt, a “list of routes” is a
singular expression. Moreover, “stored” is a state and I should not use “into”
as it expresses a change.</p>
<blockquote>
<p>The list of routes for each prefix <del>are</del> <ins>is</ins> not stored
directly <del>into</del> <ins>in</ins> the prefix tree.</p>
</blockquote>
<p>As a last example, consider the following snippet. The “<a href="https://dictionary.cambridge.org/dictionary/english/require" title="Definition of require in the Cambridge Dictionary">require</a>” verb
accepts a noun or an object followed by a to-infinitive. I can’t use it with
just a to-infinitive.</p>
<blockquote>
<p>An alternative would be to have one prefix tree for each peer but it would
require <del>to configure</del> <ins>configuring</ins> all routers to export
their routes.</p>
</blockquote>
<p>As someone who didn’t grow up speaking English, I struggle with these grammar
rules despite reading a lot of English material.<sup id="fnref:monkey2"><a class="footnote-ref" href="#fn:monkey2">3</a></sup> French is more
complex to get started but more systematic. English is full of irregularities.</p>
<hr/>
<p>On each page, I disclose in the footer whether an <abbr title="Artificial Intelligence">AI</abbr> modified the content. There
are three levels:</p>
<ul>
<li>🧠: no <abbr title="Artificial Intelligence">AI</abbr> or almost no <abbr title="Artificial Intelligence">AI</abbr> (e.g., grammar corrections)</li>
<li>✨: enhanced (e.g., copyediting)</li>
<li>🤖: generated (e.g., translated from another language, even if human-edited)</li>
</ul>
<p>Hover or tap the icon to reveal the <abbr title="Artificial Intelligence">AI</abbr>’s name and its role in the document.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 264px"><span class="lf-media-inner" style="padding-bottom: 45.833%"><img alt="Screenshot of the footer containing the &quot;sparkles&quot;&#10;emoji" src="https://media-bernat-ch.b-cdn.net/images/ai-usage@1x.7529ae9860ad5e.jpg" srcset="https://media-bernat-ch.b-cdn.net/images/ai-usage@1x.7529ae9860ad5e.jpg 332w,https://media-bernat-ch.b-cdn.net/images/ai-usage@2x.3da5b6d120841a.jpg 528w" sizes="auto, (max-width: 264px) 100vw, 264px" width="264" height="121" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAhEAAADyAQMAAADJHwVUAAAAA1BMVEUiIiLuYaiQAAAAJklEQVR42u3BAQ0AAADCoPdPbQ8HFAAAAAAAAAAAAAAAAAAAAPBiQEgAAWwH/fsAAAAASUVORK5CYII=)"/></span></div><figcaption>Example of AI usage disclosure: Claude Sonnet 4.5 edited this article.</figcaption></figure>
<p>The graph below shows which tool altered each post, year by year. Recently, I
applied the <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/main/.claude/skills/grammar/SKILL.md">grammar skill</a> to <a href="https://github.com/vincentbernat/vincent.bernat.ch/commit/7baf2c8f18b57e351cc25e7e62f4aa6611362c8c" title="content: fix English grammar">past articles</a>. Since 2018,
French articles have been translated with the help of <a href="https://www.deepl.com">DeepL</a> first, then of
an <abbr title="Large Language Model">LLM</abbr>. Since 2024, English articles are copyedited.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 733px"><span class="lf-media-inner" style="padding-bottom: 68.349%"><object width="733" height="501" type="image/svg+xml" data="https://media-bernat-ch.b-cdn.net/images/ai-usage.21635bee7503a4.svg" class="lf-media">🖼 Graph showing the AI usage over the years. Each level get its own
color.</object></span></div><figcaption>AI usage over the years. Hover or tap a band for the details.</figcaption></figure>
<hr/>
<p>If you are strongly against any usage of <abbr title="Large Language Models">LLMs</abbr> specifically for writing, I hope
you accept my more nuanced position on the usage of these tools as a trade-off
to provide clearer and more engaging articles. Years of literature on improving
English told us it is important to choose the right word to keep the reader
engaged.</p>
<blockquote>
<p>[…] Good writing consists of mastering the fundamentals (vocabulary,
grammar, the elements of style) and then filling the third level of your
toolbox with the right instruments.</p>
<p>― <em>Stephen King</em>, On Writing</p>
</blockquote>
<div class="admonition">
<p class="admonition-title">Note</p>
<p>Unlike other recent articles, I did not use an <abbr title="Large Language Model">LLM</abbr> to edit this post:
an unnamed person kindly accepted to proofread it. I translated it to <a href="/fr/blog/2026-blog-ia" title="Tenir un blog bilingue avec l'aide d'une IA">French</a>
without using an <abbr title="Large Language Model">LLM</abbr> either.</p>
</div>
<div class="admonition">
<p class="admonition-title">Update (2026-06)</p>
<p>See the <a href="https://lobste.rs/s/tdvu7a/blogging_with_llm_assistant">story associated to this post on Lobsters</a>,
as well as the article “<a href="https://writethatblog.substack.com/p/dev-reaction-to-ai-blog-posts" title="Report: How developers react to AI-scented blog posts">How developers react to <abbr title="Artificial Intelligence">AI</abbr>-scented blog posts</a>” by
Cynthia Dunlop, one of the coauthor of “<a href="https://www.manning.com/books/writing-for-developers" title="“Writing for Developers” by Piotr Sarna and Cynthia Dunlop">Writing for Developers</a>.”</p>
</div>
<div class="footnote">
<hr/>
<ol>
<li id="fn:book">
<p>I recently read cover to cover “<a href="https://www.manning.com/books/writing-for-developers" title="“Writing for Developers” by Piotr Sarna and Cynthia Dunlop">Writing for Developers</a>” and I found
it stimulating. <a href="https://mtlynch.io/about/">Michael Lynch</a> is currently writing “<a href="https://refactoringenglish.com/" title="“Refactoring English: Effective Writing for Software Developers” by Michael Lynch">Refactoring
English</a>” on the same topic and I have subscribed to the early access. <a class="footnote-backref" href="#fnref:book" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:kagi">
<p>I am quite happy with the writing tools provided by <a href="https://www.kagi.com">Kagi</a>. Both the
<a href="https://translate.kagi.com">translate tool</a> and the <a href="https://translate.kagi.com/dictionary">dictionary</a> are a valuable help to find
different wordings. I also lean on <a href="https://help.kagi.com/kagi/ai/kagi-research.html" title="Kagi Research Assistants">Kagi’s research assistant</a> when
researching a topic. <a class="footnote-backref" href="#fnref:kagi" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:monkey2">
<p>When I was ten, I played <em>Monkey Island 2</em> in English without having
taken any classes. I used a dictionary to translate word by word and I found
the irregular verbs confusing—and not in the dictionary. <a class="footnote-backref" href="#fnref:monkey2" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">CSS &amp; vertical rhythm for text, images, and tables</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/en/blog/2026-css-vertical-rhythm" rel="alternate"/>
    <link href="https://vincent.bernat.ch/en/blog/2026-css-vertical-rhythm#isso-thread" rel="replies" type="text/html"/>
    <updated>2026-04-22T19:48:10Z</updated>
    <id>http://www.luffy.cx/en/blog/2026-css-vertical-rhythm.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>Vertical rhythm aligns lines to a consistent spacing cadence down the page. It
creates a predictable flow for the eye to follow. Thanks to the <code>rlh</code> CSS unit,
vertical rhythm is now easier to implement for text.<sup id="fnref:pawel"><a class="footnote-ref" href="#fn:pawel">1</a></sup> But illustrations
and tables can disrupt the layout. The amateur typographer in me wants to follow
Bringhurst’s wisdom:</p>
<blockquote>
<p>Headings, subheads, block quotations, footnotes, illustrations, captions and
other intrusions into the text create syncopations and variations against the
base rhythm of regularly leaded lines. These variations can and should add
life to the page, but the main text should also return after each variation
precisely on beat and in phase.</p>
<p>― <em>Robert Bringhurst</em>, <a href="https://en.wikipedia.org/wiki/The_Elements_of_Typographic_Style" title="The Elements of Typographic Style on Wikipedia">The Elements of Typographic Style</a></p>
</blockquote>
<h1 id="text">Text</h1>
<p>Three factors govern vertical rhythm: <strong>font size</strong>, <strong>line height</strong> and
<strong>margin or padding</strong>. Let’s set our baseline with an 18-pixel font and a 1.5
line height:</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">html</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mf">112.5</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mf">1.5</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">h1</span><span class="o">,</span><span class="w"> </span><span class="nt">h2</span><span class="o">,</span><span class="w"> </span><span class="nt">h3</span><span class="o">,</span><span class="w"> </span><span class="nt">h4</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mi">100</span><span class="kt">%</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">html</span><span class="o">,</span><span class="w"> </span><span class="nt">body</span><span class="o">,</span>
<span class="nt">h1</span><span class="o">,</span><span class="w"> </span><span class="nt">h2</span><span class="o">,</span><span class="w"> </span><span class="nt">h3</span><span class="o">,</span><span class="w"> </span><span class="nt">h4</span><span class="o">,</span>
<span class="nt">p</span><span class="o">,</span><span class="w"> </span><span class="nt">blockquote</span><span class="o">,</span>
<span class="nt">dl</span><span class="o">,</span><span class="w"> </span><span class="nt">dt</span><span class="o">,</span><span class="w"> </span><span class="nt">dd</span><span class="o">,</span><span class="w"> </span><span class="nt">ol</span><span class="o">,</span><span class="w"> </span><span class="nt">ul</span><span class="o">,</span><span class="w"> </span><span class="nt">li</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">margin</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">padding</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p><a href="https://www.w3.org/TR/css-values-4/" title="CSS Values and Units Module Level 4, W3C Working Draft">CSS Values and Units Module Level 4</a> defines the <code>rlh</code> unit, equal to the
computed line height of the root element. All browsers support it <a href="https://webstatus.dev/features/rlh" title="rlh unit on Web Platform Status">since
2023</a>.<sup id="fnref:postcss"><a class="footnote-ref" href="#fn:postcss">2</a></sup> Use it to insert vertical spaces or to fix the line height
when altering font size:<sup id="fnref:calc"><a class="footnote-ref" href="#fn:calc">3</a></sup></p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">h1</span><span class="o">,</span><span class="w"> </span><span class="nt">h2</span><span class="o">,</span><span class="w"> </span><span class="nt">h3</span><span class="o">,</span><span class="w"> </span><span class="nt">h4</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">margin-top</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="kt">rlh</span><span class="p">;</span>
<span class="w">  </span><span class="k">margin-bottom</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">h1</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mf">2.5</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">h2</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mf">1.5</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">h3</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mf">1.25</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">p</span><span class="o">,</span><span class="w"> </span><span class="nt">blockquote</span><span class="o">,</span><span class="w"> </span><span class="nt">pre</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">margin-top</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">aside</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-size</span><span class="p">:</span><span class="w"> </span><span class="mf">0.875</span><span class="kt">rem</span><span class="p">;</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p>We can check the result by overlaying a grid<sup id="fnref:grid"><a class="footnote-ref" href="#fn:grid">4</a></sup> on the content:</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 680px"><span class="lf-media-inner" style="padding-bottom: 104.853%"><img alt="Screenshot of my website with a grid as an overlay and each line of text&#10;fitting on the grid" src="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/text@1x.86066b981d3272.png" srcset="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/text@1x.86066b981d3272.png 855w,https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/text@2x.2efca27c1a0b61.png 1360w" sizes="auto, (max-width: 680px) 100vw, 680px" width="680" height="713" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAqgAAALJAQMAAABC3kPgAAAAA1BMVEX59O2zBM/5AAAAUklEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+DHvhgABu8TltwAAAABJRU5ErkJggg==)"/></span></div><figcaption>Using CSS <code>rlh</code> unit to set vertical space works well for text. You can display the grid using <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd>.</figcaption></figure>
<p>If a child element uses a font with taller intrinsic metrics, it may stretch
the line’s box beyond the configured line height.<sup id="fnref:line-height"><a class="footnote-ref" href="#fn:line-height">5</a></sup> A workaround
is to reduce the line height to 1. The glyphs overflow but don’t push the line
taller.</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">code</span><span class="o">,</span><span class="w"> </span><span class="nt">kbd</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">line-height</span><span class="p">:</span><span class="w"> </span><span class="mi">1</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<h1 id="responsive-images">Responsive images</h1>
<p>Responsive images are difficult to align on the grid because we don’t know their
height. <a href="https://www.w3.org/TR/css-rhythm-1/" title="CSS Rhythmic Sizing Module Level 1">CSS Rhythmic Sizing Module Level 1</a> introduces the <code>block-step</code>
property to adjust the height of an element to a multiple of a step unit. But
most browsers don’t support it yet.</p>
<p>With JavaScript, we can add padding around the image so it does not disturb
the vertical rhythm:</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="kd">const</span><span class="w"> </span><span class="nx">targets</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">querySelectorAll</span><span class="p">(</span><span class="s2">".lf-media-outer"</span><span class="p">);</span>
<span class="kd">const</span><span class="w"> </span><span class="nx">adjust</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">(</span><span class="nx">el</span><span class="p">,</span><span class="w"> </span><span class="nx">height</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">rlh</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">parseFloat</span><span class="p">(</span><span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">documentElement</span><span class="p">).</span><span class="nx">lineHeight</span><span class="p">);</span>
<span class="w">  </span><span class="kd">const</span><span class="w"> </span><span class="nx">padding</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nb">Math</span><span class="p">.</span><span class="nx">ceil</span><span class="p">(</span><span class="nx">height</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="nx">rlh</span><span class="p">)</span><span class="w"> </span><span class="o">*</span><span class="w"> </span><span class="nx">rlh</span><span class="w"> </span><span class="o">-</span><span class="w"> </span><span class="nx">height</span><span class="p">;</span>
<span class="w">  </span><span class="nx">el</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">padding</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="sb">`</span><span class="si">${</span><span class="nx">padding</span><span class="w"> </span><span class="o">/</span><span class="w"> </span><span class="mf">2</span><span class="si">}</span><span class="sb">px 0`</span><span class="p">;</span>
<span class="p">};</span>

<span class="nx">targets</span><span class="p">.</span><span class="nx">forEach</span><span class="p">((</span><span class="nx">el</span><span class="p">)</span><span class="w"> </span><span class="p">=&gt;</span><span class="w"> </span><span class="nx">adjust</span><span class="p">(</span><span class="nx">el</span><span class="p">,</span><span class="w"> </span><span class="nx">el</span><span class="p">.</span><span class="nx">clientHeight</span><span class="p">));</span>
</code></pre></div>

<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 680px"><span class="lf-media-inner" style="padding-bottom: 104.853%"><img alt="Screenshot of my website with a grid as an overlay and an image not breaking&#10;the vertical rhythm. Additional padding is visible before and after the image.&#10;The height of the image with padding is&#10;216." src="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/images@1x.fbe2d0c8a1faed.png" srcset="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/images@1x.fbe2d0c8a1faed.png 855w,https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/images@2x.cb1dff66666113.png 1360w" sizes="auto, (max-width: 680px) 100vw, 680px" width="680" height="713" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAqgAAALJAQMAAABC3kPgAAAAA1BMVEX38usGo+BAAAAAUklEQVR42u3BAQ0AAADCoPdPbQ43oAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+DHvhgABu8TltwAAAABJRU5ErkJggg==)"/></span></div><figcaption>The image is snapped to the grid thanks to the additional padding computed with JavaScript. 216 is divisible by 27, our line height in this example.</figcaption></figure>
<p>As the image is responsive, its height can change. We need to wrap a resize
observer around the <code>adjust()</code> function:</p>
<div class="language-javascript codehilite"><pre><span/><code><span class="kd">const</span><span class="w"> </span><span class="nx">ro</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">ResizeObserver</span><span class="p">((</span><span class="nx">entries</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="k">for</span><span class="w"> </span><span class="p">(</span><span class="kd">const</span><span class="w"> </span><span class="nx">entry</span><span class="w"> </span><span class="k">of</span><span class="w"> </span><span class="nx">entries</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">height</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="nx">entry</span><span class="p">.</span><span class="nx">contentBoxSize</span><span class="p">[</span><span class="mf">0</span><span class="p">].</span><span class="nx">blockSize</span><span class="p">;</span>
<span class="w">    </span><span class="nx">adjust</span><span class="p">(</span><span class="nx">entry</span><span class="p">.</span><span class="nx">target</span><span class="p">,</span><span class="w"> </span><span class="nx">height</span><span class="p">);</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">});</span>
<span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="kd">const</span><span class="w"> </span><span class="nx">target</span><span class="w"> </span><span class="k">of</span><span class="w"> </span><span class="nx">targets</span><span class="p">)</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="nx">ro</span><span class="p">.</span><span class="nx">observe</span><span class="p">(</span><span class="nx">target</span><span class="p">);</span>
<span class="p">}</span>
</code></pre></div>

<h1 id="tables">Tables</h1>
<p>Table cells could set <code>1rlh</code> as their height but they would feel constricted.
Using <code>2rlh</code> wastes too much space. Instead, we use <a href="https://markboulton.co.uk/journal/incremental-leading/" title="Incremental leading">incremental leading</a>: we
align one in every five lines.</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">table</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">border-spacing</span><span class="p">:</span><span class="w"> </span><span class="mi">2</span><span class="kt">px</span><span class="w"> </span><span class="mi">0</span><span class="p">;</span>
<span class="w">  </span><span class="k">border-collapse</span><span class="p">:</span><span class="w"> </span><span class="kc">separate</span><span class="p">;</span>
<span class="w">  </span><span class="nt">th</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">padding</span><span class="p">:</span><span class="w"> </span><span class="mf">0.4</span><span class="kt">rlh</span><span class="w"> </span><span class="mi">1</span><span class="kt">em</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="w">  </span><span class="nt">td</span><span class="w"> </span><span class="p">{</span>
<span class="w">    </span><span class="k">padding</span><span class="p">:</span><span class="w"> </span><span class="mf">0.2</span><span class="kt">rlh</span><span class="w"> </span><span class="mf">0.5</span><span class="kt">em</span><span class="p">;</span>
<span class="w">  </span><span class="p">}</span>
<span class="p">}</span>
</code></pre></div>

<p>To align the elements after the table, we need to add some padding. We can
either reuse the JavaScript code from images or use a few lines of CSS that
count the regular rows and compute the missing vertical padding:</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">table</span><span class="p">:</span><span class="nd">has</span><span class="o">(</span><span class="nt">tbody</span><span class="w"> </span><span class="nt">tr</span><span class="p">:</span><span class="nd">nth-child</span><span class="o">(</span><span class="nt">5n</span><span class="o">)</span><span class="p">:</span><span class="nd">last-child</span><span class="o">)</span><span class="w">   </span><span class="p">{</span><span class="w"> </span><span class="k">padding-bottom</span><span class="p">:</span><span class="w"> </span><span class="mf">0.2</span><span class="kt">rlh</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="nt">table</span><span class="p">:</span><span class="nd">has</span><span class="o">(</span><span class="nt">tbody</span><span class="w"> </span><span class="nt">tr</span><span class="p">:</span><span class="nd">nth-child</span><span class="o">(</span><span class="nt">5n</span><span class="o">+</span><span class="nt">1</span><span class="o">)</span><span class="p">:</span><span class="nd">last-child</span><span class="o">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">padding-bottom</span><span class="p">:</span><span class="w"> </span><span class="mf">0.8</span><span class="kt">rlh</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="nt">table</span><span class="p">:</span><span class="nd">has</span><span class="o">(</span><span class="nt">tbody</span><span class="w"> </span><span class="nt">tr</span><span class="p">:</span><span class="nd">nth-child</span><span class="o">(</span><span class="nt">5n</span><span class="o">+</span><span class="nt">2</span><span class="o">)</span><span class="p">:</span><span class="nd">last-child</span><span class="o">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">padding-bottom</span><span class="p">:</span><span class="w"> </span><span class="mf">0.4</span><span class="kt">rlh</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
<span class="nt">table</span><span class="p">:</span><span class="nd">has</span><span class="o">(</span><span class="nt">tbody</span><span class="w"> </span><span class="nt">tr</span><span class="p">:</span><span class="nd">nth-child</span><span class="o">(</span><span class="nt">5n</span><span class="o">+</span><span class="nt">3</span><span class="o">)</span><span class="p">:</span><span class="nd">last-child</span><span class="o">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">padding-bottom</span><span class="p">:</span><span class="w"> </span><span class="mi">0</span><span class="w"> </span><span class="p">}</span>
<span class="nt">table</span><span class="p">:</span><span class="nd">has</span><span class="o">(</span><span class="nt">tbody</span><span class="w"> </span><span class="nt">tr</span><span class="p">:</span><span class="nd">nth-child</span><span class="o">(</span><span class="nt">5n</span><span class="o">+</span><span class="nt">4</span><span class="o">)</span><span class="p">:</span><span class="nd">last-child</span><span class="o">)</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="k">padding-bottom</span><span class="p">:</span><span class="w"> </span><span class="mf">0.6</span><span class="kt">rlh</span><span class="p">;</span><span class="w"> </span><span class="p">}</span>
</code></pre></div>

<p>A header cell has twice the padding of a regular cell. With two regular rows,
the total padding is 2×2×0.2+2×0.4=1.6. We need to add <code>0.4rlh</code> to reach
<code>2rlh</code> of extra vertical padding across the table.</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 680px"><span class="lf-media-inner" style="padding-bottom: 86.765%"><img alt="Screenshot of my website with a grid as an overlay and a table following the&#10;vertical rhythm. Additional padding is visible after the table. The height of&#10;the table with padding is 405." src="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/tables@1x.04a0681e41828c.png" srcset="https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/tables@1x.04a0681e41828c.png 855w,https://media-bernat-ch.b-cdn.net/images/vertical-rhythm/tables@2x.8f92851640c630.png 1360w" sizes="auto, (max-width: 680px) 100vw, 680px" width="680" height="590" class="lf-media lf-opaque" style="background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEQAAAA7AQMAAAD4qb7lAAAAA1BMVEWx3O2Q0L0rAAAAD0lEQVR42mNgGAWjgFoAAAJOAAFGvjzoAAAAAElFTkSuQmCC)"/></span></div><figcaption>One line out of five is aligned to the grid. Additional padding is added after the table to not break the vertical rhythm. 405 is divisible by 27, our line height in this example.</figcaption></figure>
<hr/>
<p>None of this is necessary. But once you start looking, you can’t unsee it. Until
browsers implement <a href="https://www.w3.org/TR/css-rhythm-1/" title="CSS Rhythmic Sizing Module Level 1">CSS Rhythmic Sizing</a>, a
bit of CSS wizardry and a touch of JavaScript is enough to pull it off. The main
text now returns after each intrusion “precisely on beat and in phase.” 🎼</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:pawel">
<p>See “<a href="https://pawelgrzybek.com/vertical-rhythm-using-css-lh-and-rlh-units/" title="Vertical rhythm using CSS lh and rlh units">Vertical rhythm using CSS <code>lh</code> and <code>rlh</code> units</a>” by Paweł
Grzybek. Previously, a good reference was “<a href="http://lamb.cc/typograph/" title="Scale &amp; Rhythm">Scale &amp; Rhythm</a>” from Iain Lamb. <a class="footnote-backref" href="#fnref:pawel" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
<li id="fn:postcss">
<p>For broader compatibility, you can replace <code>2rlh</code> with
<code>calc(var(--line-height) * 2rem)</code> and set the <code>--line-height</code> custom
property in the <code>:root</code> pseudo-class. I wrote a <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/95f29793faad50a94fe4199a1774d59d409d3755/extensions/css.js#L69-L79">simple PostCSS
plugin</a> for this purpose. <a class="footnote-backref" href="#fnref:postcss" title="Jump back to footnote 2 in the text">↩</a></p>
</li>
<li id="fn:calc">
<p>It would have been nicer to compute the line height with
<code>calc(round(up, calc(2.4rem / 1rlh), 0) * 1rlh)</code>. Unfortunately, typed
arithmetic is <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1264520" title="Support mixed number, unit value, and percent for calc() parser including type checking">not supported by Firefox yet</a>. Moreover, browsers support
<code>round()</code> only <a href="https://webstatus.dev/features/round-mod-rem" title="round() function on Web Platform Status">since 2024</a>. Instead, I coded a <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/95f29793faad50a94fe4199a1774d59d409d3755/extensions/css.js#L31-L67">PostCSS
plugin</a> for this as well. <a class="footnote-backref" href="#fnref:calc" title="Jump back to footnote 3 in the text">↩</a></p>
</li>
<li id="fn:grid">
<p>The following CSS code defines a grid tracking the line height:</p>
<div class="language-css codehilite"><pre><span/><code><span class="nt">body</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">relative</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt">body</span><span class="p">::</span><span class="nd">after</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">content</span><span class="p">:</span><span class="w"> </span><span class="s2">""</span><span class="p">;</span>
<span class="w">  </span><span class="k">position</span><span class="p">:</span><span class="w"> </span><span class="kc">absolute</span><span class="p">;</span>
<span class="w">  </span><span class="k">inset</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">z-index</span><span class="p">:</span><span class="w"> </span><span class="mi">9999</span><span class="p">;</span>
<span class="w">  </span><span class="k">background</span><span class="p">:</span><span class="w"> </span><span class="nb">linear-gradient</span><span class="p">(</span><span class="mi">180</span><span class="kt">deg</span><span class="p">,</span><span class="w"> </span><span class="mh">#c8e1ff</span><span class="mi">99</span><span class="w"> </span><span class="mi">1</span><span class="kt">px</span><span class="p">,</span><span class="w"> </span><span class="kc">transparent</span><span class="w"> </span><span class="mi">1</span><span class="kt">px</span><span class="p">);</span>
<span class="w">  </span><span class="k">background-size</span><span class="p">:</span><span class="w"> </span><span class="mi">20</span><span class="kt">px</span><span class="w"> </span><span class="mi">1</span><span class="kt">rlh</span><span class="p">;</span>
<span class="w">  </span><span class="k">pointer-events</span><span class="p">:</span><span class="w"> </span><span class="kc">none</span><span class="p">;</span>
<span class="p">}</span>
</code></pre></div>

<p><a class="footnote-backref" href="#fnref:grid" title="Jump back to footnote 4 in the text">↩</a></p>
</li>
<li id="fn:line-height">
<p>See “<a href="https://iamvdo.me/en/blog/css-font-metrics-line-height-and-vertical-align">Deep dive CSS: font metrics, line-height and vertical-align</a>” by Vincent De Oliveira. <a class="footnote-backref" href="#fnref:line-height" title="Jump back to footnote 5 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
  <entry>
    <title type="html">Fixing layout shifts caused by web fonts</title>
    <author><name>Vincent Bernat</name></author>
    <link href="https://vincent.bernat.ch/en/blog/2024-cls-webfonts" rel="alternate"/>
    <link href="https://vincent.bernat.ch/en/blog/2024-cls-webfonts#isso-thread" rel="replies" type="text/html"/>
    <updated>2024-08-31T11:07:13Z</updated>
    <id>http://www.luffy.cx/en/blog/2024-cls-webfonts.html</id>

    <content type="xhtml">
      <div xmlns="http://www.w3.org/1999/xhtml">
<p>In 2020, Google introduced <a href="https://web.dev/articles/defining-core-web-vitals-thresholds" title="Defining the Core Web Vitals metrics thresholds">Core Web Vitals metrics</a> to measure some aspects
of real-world user experience on the web. This blog has consistently achieved
good scores for two of these metrics: <a href="https://web.dev/articles/lcp" title="Largest Contentful Paint (LCP)">Largest Contentful Paint</a> and
<a href="https://web.dev/articles/inp" title="Interaction to Next Paint (INP)">Interaction to Next Paint</a>. However, optimizing the third metric, <a href="https://web.dev/articles/cls" title="Cumulative Layout Shift (CLS)">Cumulative
Layout Shift</a>, which measures unexpected layout changes, has been more
challenging. Let’s face it: optimizing for this metric is not really useful for
a site like this one. But getting a better score is always a good distraction. 💯</p>
<p>To prevent the “flash of invisible text” when using web fonts, developers should
set the <code>font-display</code> property to <code>swap</code> in <code>@font-face</code> rules. This method
allows browsers to initially render text using a fallback font, then replace it
with the web font after loading. While this improves the <abbr title="Large Contentful Paint">LCP</abbr> score, it causes
content reflow and layout shifts if the fallback and web fonts are not
metrically compatible. These shifts negatively affect the <abbr title="Cumulative Layout Shift">CLS</abbr> score. <abbr title="Cascading Style Sheets">CSS</abbr>
provides properties to address this issue by overriding font metrics when using
fallback fonts: <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@font-face/size-adjust"><code>size-adjust</code></a>,
<a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@font-face/ascent-override"><code>ascent-override</code></a>, <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@font-face/descent-override"><code>descent-override</code></a>,
and <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@font-face/line-gap-override"><code>line-gap-override</code></a>.</p>
<p>Two comprehensive articles explain each property and their computation methods
in detail: <a href="https://www.aleksandrhovhannisyan.com/blog/perfect-font-fallbacks/">Creating Perfect Font Fallbacks in <abbr title="Cascading Style Sheets">CSS</abbr></a> and <a href="https://developer.chrome.com/blog/font-fallbacks/">Improved font
fallbacks</a>.</p>
<div class="toc">
<ul>
<li><a href="#interactive-tuning-tool">Interactive tuning tool</a><ul>
<li><a href="#instructions">Instructions</a></li>
<li><a href="#tool">Tool</a></li>
<li><a href="#results">Results</a></li>
</ul>
</li>
<li><a href="#about-custom-fonts">About custom fonts</a></li>
</ul>
</div>
<h1 id="interactive-tuning-tool">Interactive tuning tool</h1>
<p>Instead of computing each property from font average metrics, I put together a
tool for interactively tuning fallback fonts.<sup id="fnref:others"><a class="footnote-ref" href="#fn:others">1</a></sup></p>
<h2 id="instructions">Instructions</h2>
<ol>
<li>
<p>Load your custom font.</p>
</li>
<li>
<p>Select a fallback font to tune.</p>
</li>
<li>
<p>Adjust the <code>size-adjust</code> property to match the width of your custom font with
   the fallback font. With a proportional font, it is not possible to achieve a
   perfect match.</p>
</li>
<li>
<p>Fine-tune the <code>ascent-override</code> property. Aim to align the final dot of the
   last paragraph while monitoring the font’s baseline. For more precise
   adjustment, disable the “<label for="lf1-support-line-height">Set line
   height</label>” option.</p>
</li>
<li>
<p>Modify the <code>descent-override</code> property. The goal is to make the two boxes
   match. You may need to alternate between this and the previous property for
   optimal results.</p>
</li>
<li>
<p>If necessary, adjust the <code>line-gap-override</code> property. This step is typically
   not required.</p>
</li>
</ol>
<p>The process needs to be repeated for each fallback font. Some platforms may not
include certain fonts. Notably, Android lacks most fonts found in other
operating systems. It replaces <em>Georgia</em> with <em>Noto Serif</em>, which is not
metrically compatible.</p>
<h2 id="tool">Tool</h2>

<p class="when-rss">This tool is not available from the Atom feed.</p>



<h2 id="results">Results</h2>
<p>For the body text of this blog, I get the following <abbr title="Cascading Style Sheets">CSS</abbr> definition:</p>
<div class="language-css codehilite"><pre><span/><code><span class="p">@</span><span class="k">font-face</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-family</span><span class="p">:</span><span class="w"> </span><span class="n">Merriweather</span><span class="p">;</span>
<span class="w">  </span><span class="k">font-style</span><span class="p">:</span><span class="w"> </span><span class="kc">normal</span><span class="p">;</span>
<span class="w">  </span><span class="k">font-weight</span><span class="p">:</span><span class="w"> </span><span class="mi">400</span><span class="p">;</span>
<span class="w">  </span><span class="n">src</span><span class="p">:</span><span class="w"> </span><span class="nb">url</span><span class="p">(</span><span class="s2">"../fonts/merriweather.woff2"</span><span class="p">)</span><span class="w"> </span><span class="nf">format</span><span class="p">(</span><span class="s2">"woff2"</span><span class="p">);</span>
<span class="w">  </span><span class="n">font-display</span><span class="p">:</span><span class="w"> </span><span class="n">swap</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">@</span><span class="k">font-face</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-family</span><span class="p">:</span><span class="w"> </span><span class="s2">"Fallback for Merriweather"</span><span class="p">;</span>
<span class="w">  </span><span class="n">src</span><span class="p">:</span><span class="w"> </span><span class="nf">local</span><span class="p">(</span><span class="s2">"Noto Serif"</span><span class="p">),</span><span class="w"> </span><span class="nf">local</span><span class="p">(</span><span class="s2">"Droid Serif"</span><span class="p">);</span>
<span class="w">  </span><span class="n">size-adjust</span><span class="p">:</span><span class="w"> </span><span class="mf">98.3</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="n">ascent-override</span><span class="p">:</span><span class="w"> </span><span class="mi">99</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="n">descent-override</span><span class="p">:</span><span class="w"> </span><span class="mi">27</span><span class="kt">%</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">@</span><span class="k">font-face</span><span class="w"> </span><span class="p">{</span>
<span class="w">  </span><span class="k">font-family</span><span class="p">:</span><span class="w"> </span><span class="s2">"Fallback for Merriweather"</span><span class="p">;</span>
<span class="w">  </span><span class="n">src</span><span class="p">:</span><span class="w"> </span><span class="nf">local</span><span class="p">(</span><span class="s2">"Georgia"</span><span class="p">);</span>
<span class="w">  </span><span class="n">size-adjust</span><span class="p">:</span><span class="w"> </span><span class="mi">106</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="n">ascent-override</span><span class="p">:</span><span class="w"> </span><span class="mf">90.4</span><span class="kt">%</span><span class="p">;</span>
<span class="w">  </span><span class="n">descent-override</span><span class="p">:</span><span class="w"> </span><span class="mf">27.3</span><span class="kt">%</span><span class="p">;</span>
<span class="p">}</span>

<span class="nt">font-family</span><span class="o">:</span><span class="w"> </span><span class="nt">Merriweather</span><span class="o">,</span><span class="w"> </span><span class="s2">"Fallback for Merriweather"</span><span class="o">,</span><span class="w"> </span><span class="nt">serif</span><span class="o">;</span>
</code></pre></div>

<div class="admonition">
<p class="admonition-title">Update (2026-10)</p>
<p>I switched from Merriweather to <a href="https://fonts.google.com/specimen/Literata" title="Google Fonts: Literata">Literata</a> for body text.</p>
</div>
<p>After a month, the <abbr title="Cumulative Layout Shift">CLS</abbr> metric improved to 0:</p>
<figure class="lf-fullbleed"><div class="lf-media-outer" style="width: 959px"><span class="lf-media-inner" style="padding-bottom: 48.488%"><img alt="Core Web Vitals scores for vincent.bernat.ch showing all 6 metrics as green.&#10;Notably the Cumulative Layout Shift is 0." src="https://media-bernat-ch.b-cdn.net/images/core-web-vitals.62966045af7dbc.svg" width="959" height="465" class="lf-media"/></span></div><figcaption>Recent Core Web Vitals scores for vincent.bernat.ch</figcaption></figure>
<h1 id="about-custom-fonts">About custom fonts</h1>
<p>Using <a href="https://www.cssfontstack.com/" title="CSS Font Stack">safe web fonts</a> or a <a href="https://modernfontstacks.com/" title="System font stack CSS organized by typeface classification for every modern OS">modern font stack</a> is often simpler. However, I
prefer custom web fonts. <a href="https://ebensorkin.wordpress.com/" title="Merriweather Font">Merriweather</a> and <a href="https://typeof.net/Iosevka/" title="Iosevka Font">Iosevka</a>, which are used in
this blog, enhance the reading experience. An alternative approach could be to
use <em>Georgia</em> as a serif option. Unfortunately, most default monospace fonts are
ugly.</p>
<p>Furthermore, paragraphs that combine proportional and monospace fonts can create
visual disruption. This occurs due to mismatched vertical metrics or weights. To
address this issue, I <a href="https://github.com/vincentbernat/vincent.bernat.ch/blob/199addb942c68cbeb04c3c89a5fdee9da896785f/flake.nix#L233-L236">adjust Iosevka’s metrics</a> and weight to align with
Merriweather’s characteristics.</p>
<div class="footnote">
<hr/>
<ol>
<li id="fn:others">
<p>Similar tools already exist, like the <a href="https://screenspan.net/fallback" title="Reduce Cumulative Layout Shift (CLS) by adjusting web fonts and system font fallbacks using special @font-face descriptors">Fallback Font Generator</a>,
but they were missing a few features, such as the ability to load the
fallback font or to have decimals for the <abbr title="Cascading Style Sheets">CSS</abbr> properties. And no source
code. <a class="footnote-backref" href="#fnref:others" title="Jump back to footnote 1 in the text">↩</a></p>
</li>
</ol>
</div>
      </div>
    </content>
  </entry>
</feed>