<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><title>IT Notes - hosting</title><link>https://it-notes.dragas.net/categories/hosting/</link><description>Articles in category hosting</description><atom:link href="https://it-notes.dragas.net/categories/hosting/feed.xml" rel="self"/><language>en</language><lastBuildDate>Fri, 05 Jun 2026 08:44:00 +0000</lastBuildDate><atom:link href="https://it-notes.dragas.net/categories/hosting/feed.xml" rel="self" type="application/rss+xml"/><item><title>Aggressive caching for a Mastodon reverse proxy: what to cache, what to never cache, and why content negotiation will eventually betray you</title><link>https://it-notes.dragas.net/2026/06/05/aggressive_caching_for_a_mastodon_reverse_proxy/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/web_text.webp" alt="Aggressive caching for a Mastodon reverse proxy: what to cache, what to never cache, and why content negotiation will eventually betray you"&gt;&lt;/p&gt;&lt;p&gt;I have written before about putting a cache in front of  &lt;a href="https://it-notes.dragas.net/2025/01/29/improving-snac-performance-with-nginx-proxy-cache/"&gt;snac&lt;/a&gt;, and more recently about  &lt;a href="https://it-notes.dragas.net/2026/05/18/fedimeteo-haproxy-and-the-art-of-not-wasting-snac-threads/"&gt;the HAProxy layer in front of FediMeteo&lt;/a&gt;. The general idea is always the same: the reverse proxy should absorb the repetitive, public work that has no business reaching the application server.&lt;/p&gt;
&lt;p&gt;This post is the same idea applied to a much louder neighbour: a Mastodon instance. The instance is  &lt;a href="https://mastodon.bsd.cafe/"&gt;mastodon.bsd.cafe&lt;/a&gt;, the proxy is nginx on FreeBSD, and the configuration below is what I am currently running in production.&lt;/p&gt;
&lt;p&gt;Mastodon is heavier than snac in every direction. It has Puma and Sidekiq behind it, more endpoints, more streaming, more federation patterns, and one specific characteristic that complicates everything: it serves multiple representations on the same URLs. The same path returns HTML to a browser, ActivityPub JSON to a remote instance, and sometimes plain JSON to an API client. If the proxy treats the URL as one thing, sooner or later it will return the wrong thing to the wrong client.&lt;/p&gt;
&lt;p&gt;Most of the work below comes from that single observation.&lt;/p&gt;
&lt;p&gt;If I had to summarize this whole post in a single sentence, it would be this:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mastodon is not a website. It is a website, an API, and an ActivityPub server, all sharing the same URLs.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Everything else in this configuration - cache keys, variants, bypass rules, the diagnostic headers - is decoration around that one fact.&lt;/p&gt;
&lt;p&gt;A popular toot from a friend gets boosted. Twenty federated instances ask for the same ActivityPub object within the same second. Browsers fetch the HTML version of the same URL. If the proxy sees only "a URL", it will eventually betray you: a remote instance will receive HTML, a browser will receive ActivityPub JSON, and you will spend an afternoon wondering why your timeline looks broken on three different servers. I have spent that afternoon. I do not recommend it.&lt;/p&gt;
&lt;h2&gt;Assumptions before anything else&lt;/h2&gt;
&lt;p&gt;Before any directive, this configuration assumes a few things about the instance. If any of these does not match your setup, the directives still make sense, but you must read the caveats at the end before adapting them.&lt;/p&gt;
&lt;p&gt;The first assumption is that  &lt;code&gt;AUTHORIZED_FETCH&lt;/code&gt;  (secure mode) is disabled. With secure mode off, all ActivityPub GET responses cached at the proxy layer are public and identical regardless of the requesting actor. With secure mode on, Mastodon can legitimately return different bodies depending on which remote actor is asking, and caching them blindly at the proxy becomes at best wasteful, at worst a cache-poisoning surface.&lt;/p&gt;
&lt;p&gt;This is not a hypothetical. &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2026-25540"&gt;CVE-2026-25540&lt;/a&gt;, fixed in Mastodon 4.3.19, 4.4.13, and 4.5.6, is exactly this kind of mistake, but inside Mastodon's own  &lt;code&gt;Rails.cache&lt;/code&gt;: the pinned posts and featured hashtags endpoints had actor-dependent ActivityPub responses but were keyed without the actor. The CVE does not directly apply to nginx caches, but the underlying lesson does. Do not cache what depends on the caller unless the caller is part of the cache key. Keep this rule in mind every time you are tempted to cache a federation endpoint "just in case".&lt;/p&gt;
&lt;p&gt;The second assumption is that no signed-URL storage backend sits behind  &lt;code&gt;/system/&lt;/code&gt;  or  &lt;code&gt;/media_proxy/&lt;/code&gt;. If those paths ever redirect to short-lived presigned S3 or SeaweedFS URLs, my TTLs below are too long: nginx will happily cache a redirect to a URL that has already expired.&lt;/p&gt;
&lt;p&gt;The third assumption is that federation traffic uses HTTP Signatures, not the HTTP  &lt;code&gt;Authorization&lt;/code&gt;  header. Mastodon signs federated GETs with the  &lt;code&gt;Signature&lt;/code&gt;  header. The  &lt;code&gt;Authorization&lt;/code&gt;-based skip-cache rule further down catches API tokens, not signed federation traffic. If you enable  &lt;code&gt;AUTHORIZED_FETCH&lt;/code&gt;, you must add an explicit skip rule for  &lt;code&gt;$http_signature&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;I am being deliberate about these assumptions because the configuration that follows is internally consistent only as long as they hold.&lt;/p&gt;
&lt;p&gt;The proxy in front of  &lt;code&gt;mastodon.bsd.cafe&lt;/code&gt;  has three jobs:&lt;/p&gt;
&lt;p&gt;TLS termination, microcaching of expensive endpoints (especially federation-heavy collections and default public routes), and long-lived caching of immutable assets and user media.&lt;/p&gt;
&lt;p&gt;The point is not to replace Mastodon's internal Rails cache. The point is to absorb spiky federation traffic and repetitive asset fetches that would otherwise hit Puma and Rails for every single request.&lt;/p&gt;
&lt;p&gt;The strategy is deliberately layered: very long TTL on fingerprinted assets, medium TTL on user-uploaded media, very short microcache on dynamic pages and federation endpoints that get hammered, and explicit bypass rules for anything private, authenticated, actor-dependent, or otherwise unsafe.&lt;/p&gt;
&lt;p&gt;Every cacheable layer is keyed correctly for content negotiation. That is the part that matters most.&lt;/p&gt;
&lt;h2&gt;The cache zone&lt;/h2&gt;
&lt;p&gt;A single cache zone is shared across all Mastodon locations:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;proxy_cache_path /var/cache/nginx/mastodon
                 levels=1:2
                 keys_zone=mastodon_cache:200m
                 max_size=20g
                 inactive=24h
                 use_temp_path=off;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;200m&lt;/code&gt;  of keys zone holds metadata for roughly 1.6 million entries in RAM. The body can grow up to  &lt;code&gt;20g&lt;/code&gt;  on disk. The two numbers are independent: keys live in shared memory, bodies live on the filesystem, and the cache key is what links them.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;inactive=24h&lt;/code&gt;  evicts anything not requested for a day, even if there is free space. This is intentional. I do not want a long, cold tail of stale entries to squat in the cache forever. I want the working set to remain hot, and I want the rest to fade.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;use_temp_path=off&lt;/code&gt;  is small but important. By default nginx writes a cached response to a temporary file and then renames it into place. If the temp path and cache path are on different filesystems, that cheap rename becomes a real copy. Setting  &lt;code&gt;use_temp_path=off&lt;/code&gt;  puts temporary files directly under the cache directory and avoids that trap. It is the kind of detail nobody mentions until something is suspiciously slow.&lt;/p&gt;
&lt;p&gt;Of all the maps in this configuration, only one really earns its place. This one:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;map $http_accept $mastodon_cache_variant {
    default                          &amp;quot;default&amp;quot;;
    &amp;quot;~*application/activity\+json&amp;quot;   &amp;quot;activitypub&amp;quot;;
    &amp;quot;~*application/ld\+json&amp;quot;         &amp;quot;activitypub&amp;quot;;
    &amp;quot;~*application/json&amp;quot;             &amp;quot;json&amp;quot;;
    &amp;quot;~*text/html&amp;quot;                    &amp;quot;html&amp;quot;;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Mastodon serves the same URL with different bodies depending on the  &lt;code&gt;Accept&lt;/code&gt;  header. A status URL like  &lt;code&gt;/@user/123456789&lt;/code&gt;  returns rendered HTML to a browser and an ActivityPub object to another federated instance. If you cache by URL alone, the first request that comes in wins and the next request receives the wrong content type. Instances start federating HTML, browsers start downloading JSON, and the failure is subtle enough to waste hours.&lt;/p&gt;
&lt;p&gt;The map normalizes  &lt;code&gt;Accept&lt;/code&gt;  into four buckets - &lt;code&gt;activitypub&lt;/code&gt;,  &lt;code&gt;json&lt;/code&gt;,  &lt;code&gt;html&lt;/code&gt;, and  &lt;code&gt;default&lt;/code&gt; - and the result is folded into the cache key in every location that does content negotiation:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;proxy_cache_key &amp;quot;$scheme$host$request_uri|accept=$mastodon_cache_variant&amp;quot;;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Coalescing equivalent MIME types is intentional.  &lt;code&gt;application/activity+json&lt;/code&gt;  and  &lt;code&gt;application/ld+json&lt;/code&gt;  both map to  &lt;code&gt;activitypub&lt;/code&gt;, because splitting them across two cache buckets would fragment the cache for no useful operational gain.&lt;/p&gt;
&lt;p&gt;A subtle point I want to be explicit about: I do not include  &lt;code&gt;$request_method&lt;/code&gt;  in the cache key. nginx already converts  &lt;code&gt;HEAD&lt;/code&gt;into  &lt;code&gt;GET&lt;/code&gt;  for caching purposes by default, which is what I want here. A  &lt;code&gt;HEAD&lt;/code&gt;  request on  &lt;code&gt;/@user/123&lt;/code&gt;  should hit the same cache entry as a  &lt;code&gt;GET&lt;/code&gt;  request on the same URL. Adding the method would only separate them for no benefit.&lt;/p&gt;
&lt;p&gt;During rollout I also expose the selected variant as a response header:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;add_header X-Cache-Variant $mastodon_cache_variant always;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The header is there to verify the behaviour in production. It can come off once the configuration has proved itself, but I tend to leave it on. A cache that works should be visible. A cache that is invisible can be correct, but it can also be silently wrong, and I would rather know.&lt;/p&gt;
&lt;p&gt;This is the first real gotcha, and I want to spend a moment on it because it caught me out the first time I configured a similar setup.&lt;/p&gt;
&lt;p&gt;nginx honors the upstream  &lt;code&gt;Vary&lt;/code&gt;  response header in addition to  &lt;code&gt;proxy_cache_key&lt;/code&gt;. If Mastodon emits  &lt;code&gt;Vary: Accept&lt;/code&gt;, or worse,  &lt;code&gt;Vary: Accept, Cookie, ...&lt;/code&gt;, my carefully normalized variant key gets paired with nginx's native Vary handling. The result is that the cache may still fragment on the full, un-normalized  &lt;code&gt;Accept&lt;/code&gt;  header - which defeats the entire point of the variant map.&lt;/p&gt;
&lt;p&gt;There is another, very specific failure mode on older or unpatched nginx builds. nginx stores the  &lt;code&gt;Vary&lt;/code&gt;  value in a fixed-size cache metadata field. Historically that field was 42 bytes, which is famously short and almost charmingly suspicious of being a Douglas Adams reference. Modern nginx raised the limit to 128 bytes, which is enough for the common cases but still surprisingly small. If your upstream emits a long  &lt;code&gt;Vary&lt;/code&gt;  header, anything beyond the limit is treated as  &lt;code&gt;Vary: *&lt;/code&gt;, which means the response is not cached at all. The only signal you get is a critical line in the error log, and unless you are looking for it, you will not see it.&lt;/p&gt;
&lt;p&gt;The operational lesson is the same in both cases: if you rely on your own normalized variant key, do not assume upstream  &lt;code&gt;Vary&lt;/code&gt;  is harmless. Check your nginx version, check your error log, and verify cache behaviour via  &lt;code&gt;X-Cache-Status&lt;/code&gt;and  &lt;code&gt;X-Cache-Variant&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;On the locations where the variant map is the cache dimension I care about, I take responsibility explicitly:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;proxy_ignore_headers Vary;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This tells nginx to stop using upstream  &lt;code&gt;Vary&lt;/code&gt;  to protect me. That is fine only if my own cache key and request normalization cover every response dimension that matters. In particular, I make sure the backend is not also varying on  &lt;code&gt;Accept-Encoding&lt;/code&gt;  in a way that would create compressed and uncompressed variants behind my back. The cleanest way to avoid that is not to forward  &lt;code&gt;Accept-Encoding&lt;/code&gt;  to the backend at all, and let frontend nginx handle compression itself:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;proxy_set_header Accept-Encoding &amp;quot;&amp;quot;;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This is the kind of decision I prefer to be explicit about. Ignoring  &lt;code&gt;Vary&lt;/code&gt;  is not magic. It is a responsibility, and it should be paired with the rules that take its place.&lt;/p&gt;
&lt;p&gt;Rather than build one giant boolean to decide what bypasses cache, I prefer to decompose the logic into small orthogonal maps. Each map is  &lt;code&gt;1&lt;/code&gt;  when caching must be skipped, and the final decision is an OR of all of them.&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;map $request_method $skip_cache_method {
    default 1;
    GET     0;
    HEAD    0;
}

map $http_authorization $skip_cache_auth {
    default 1;
    &amp;quot;&amp;quot;      0;
}

map $http_cookie $skip_cache_cookie {
    default 1;
    &amp;quot;&amp;quot;      0;
}

map $uri $skip_cache_uri {
    default                  0;
    ~^/auth                  1;
    ~^/oauth                 1;
    ~^/settings              1;
    ~^/admin                 1;
    ~^/api/v1/custom_emojis$ 0;
    ~^/api/v1/instance$      0;
    ~^/api/v2/instance$      0;
    ~^/api/v1/trends/tags$   0;
    ~^/api/oembed$           0;
    ~^/api/                  1;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The reasoning is straightforward. Only  &lt;code&gt;GET&lt;/code&gt;  and  &lt;code&gt;HEAD&lt;/code&gt;  are cacheable; everything else, including  &lt;code&gt;POST&lt;/code&gt;,  &lt;code&gt;DELETE&lt;/code&gt;,  &lt;code&gt;PUT&lt;/code&gt;, and ActivityPub deliveries, must pass through. Any request carrying an  &lt;code&gt;Authorization&lt;/code&gt;  header is an API call with a token, and those are never public. Any request with a cookie is potentially logged-in traffic, and caching logged-in pages would leak personal timelines across users. Auth flows, settings, admin, and most of the API bypass the cache by URI, while a small, carefully chosen set of slow-changing public API endpoints is allowed through.&lt;/p&gt;
&lt;p&gt;The important caveat I want to underline: the  &lt;code&gt;Authorization&lt;/code&gt;  map does  &lt;em&gt;not&lt;/em&gt;  catch signed federated GETs. Mastodon federation uses HTTP Signatures, which means the relevant request header is  &lt;code&gt;Signature&lt;/code&gt;. If  &lt;code&gt;AUTHORIZED_FETCH&lt;/code&gt;  is enabled, you must add a parallel map:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;map $http_signature $skip_cache_signature {
    default 1;
    &amp;quot;&amp;quot;      0;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;and then include it in both  &lt;code&gt;proxy_cache_bypass&lt;/code&gt;  and  &lt;code&gt;proxy_no_cache&lt;/code&gt;. Do this before enabling secure mode, not after.&lt;/p&gt;
&lt;p&gt;The maps are used together in each cacheable location:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
proxy_no_cache     $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Both directives are necessary.  &lt;code&gt;proxy_cache_bypass&lt;/code&gt;  means "do not read from cache for this request".  &lt;code&gt;proxy_no_cache&lt;/code&gt;  means "do not write this response to cache". Without  &lt;code&gt;proxy_no_cache&lt;/code&gt;, a logged-in user's response could still poison the anonymous cache. Without  &lt;code&gt;proxy_cache_bypass&lt;/code&gt;, a request that should have gone straight to the backend might still receive a cached anonymous response. I keep both, every time.&lt;/p&gt;
&lt;p&gt;Most locations share a common proxy baseline. There is nothing clever here, but if any of these lines is missing the rest of the configuration quietly does less than expected.&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection &amp;quot;&amp;quot;;
proxy_set_header Accept-Encoding &amp;quot;&amp;quot;;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;code&gt;proxy_http_version 1.1&lt;/code&gt;  and  &lt;code&gt;proxy_set_header Connection ""&lt;/code&gt;  matter for upstream keepalive. Without them, nginx may use HTTP/1.0 semantics upstream and send  &lt;code&gt;Connection: close&lt;/code&gt;  on every request, which makes the  &lt;code&gt;keepalive&lt;/code&gt;  directive on the upstream block far less useful than it looks.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;proxy_set_header Accept-Encoding ""&lt;/code&gt;  keeps backend responses uncompressed so nginx can cache a single representation and handle client-facing compression itself. It also prevents accidental cache fragmentation through  &lt;code&gt;Vary: Accept-Encoding&lt;/code&gt;, which would otherwise creep in despite the variant map.&lt;/p&gt;
&lt;p&gt;These settings are not exciting, and they should not be. The interesting parts of an infrastructure are not always the parts that should be unusual.&lt;/p&gt;
&lt;p&gt;The Mastodon  &lt;code&gt;server&lt;/code&gt;  block in my configuration ends up with seven distinct request profiles. Six of them cache; one explicitly does not, because streaming is not a cacheable workload.&lt;/p&gt;
&lt;p&gt;I do not group them under one  &lt;code&gt;location /&lt;/code&gt;  with a giant  &lt;code&gt;if&lt;/code&gt;  block. I prefer to keep each profile in its own location, even if some of them look similar. When something goes wrong in production, I want to be able to point at one location and reason about it without holding the rest of the configuration in my head.&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;location ~ ^/(assets|packs|emoji)/ {
    proxy_cache mastodon_cache;
    proxy_cache_key &amp;quot;$scheme$host$request_uri&amp;quot;;
    proxy_ignore_headers Vary;

    proxy_cache_valid 200 301 302 7d;
    proxy_cache_valid 404 10m;

    proxy_cache_lock on;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_background_update on;

    proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
    proxy_no_cache     $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;

    proxy_pass http://$custom_upstream;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;These paths are content-addressed. Webpack fingerprints filenames with hashes, so a new deploy publishes new URLs while the old URLs remain valid. A 7-day TTL is safe because  &lt;code&gt;/packs/js/common-abc123.js&lt;/code&gt;  will never become different content under the same URL. If it does, it has a new URL.&lt;/p&gt;
&lt;p&gt;404s get a short 10-minute TTL so a temporarily missing asset can recover quickly.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;proxy_cache_lock on&lt;/code&gt;  is the thundering-herd guard. When a popular asset is not cached and ten clients ask for it at once, nine wait for the first request to populate the cache instead of all ten hammering the backend. I like this directive a lot. It is the kind of small switch that quietly removes a class of problems.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;proxy_cache_use_stale&lt;/code&gt;  together with  &lt;code&gt;proxy_cache_background_update&lt;/code&gt;  is the stale-while-revalidate pattern. If an entry has expired but Mastodon is slow or briefly down, nginx can serve the stale copy and refresh it asynchronously. For static assets this is almost always the right trade-off. The asset has not actually changed under the same URL, and a few extra hours of stale data hurt nobody.&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;location ~ ^/system/(accounts/avatars|media_attachments/files|custom_emojis/images)/ {
    proxy_cache mastodon_cache;
    proxy_cache_key &amp;quot;$scheme$host$request_uri&amp;quot;;
    proxy_ignore_headers Vary;

    proxy_cache_valid 200 302 6h;
    proxy_cache_valid 404 5m;

    proxy_cache_lock on;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_background_update on;

    proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
    proxy_no_cache     $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;

    proxy_pass http://$custom_upstream;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Avatars, attachment thumbnails, and custom emoji are also effectively content-addressed, because the file path contains an ID. They can still be replaced or deleted, so the TTL is more conservative than for assets: six hours instead of seven days.&lt;/p&gt;
&lt;p&gt;The 302 status is also cached, because Mastodon may redirect to another storage location, and the redirect is usually stable enough to cache for hours.&lt;/p&gt;
&lt;p&gt;This is also where the caveat about signed URLs really matters. If you ever put a signed-URL backend behind  &lt;code&gt;/system/&lt;/code&gt;, this TTL must be shorter than the signed URL lifetime, or nginx will eventually serve a redirect to a URL that no longer works. On  &lt;code&gt;mastodon.bsd.cafe&lt;/code&gt;  I do not use signed URLs, so six hours is fine.&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;location ~ ^/(users|ap/users)/[^/]+/statuses/[0-9]+/replies {
    proxy_cache mastodon_cache;
    proxy_cache_key &amp;quot;$scheme$host$request_uri|accept=$mastodon_cache_variant&amp;quot;;
    proxy_ignore_headers Vary;

    proxy_cache_valid 200 30s;
    proxy_cache_valid 404 10s;

    proxy_cache_lock on;
    proxy_cache_lock_timeout 1s;
    proxy_cache_lock_age 5s;

    proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
    proxy_no_cache     $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;

    proxy_pass http://$custom_upstream;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This is the location I tuned most carefully. When a status starts going viral, dozens of federated instances poll  &lt;code&gt;/replies&lt;/code&gt;  to build their thread view, often within the same second. The same URL must serve an HTML thread view to browsers and an ActivityPub  &lt;code&gt;OrderedCollection&lt;/code&gt;  to remote instances, so the variant key is essential here.&lt;/p&gt;
&lt;p&gt;A 30-second microcache absorbs the spike without serving meaningfully stale data. A reply that appears 30 seconds late in a federated thread is usually invisible to humans, while the backend relief is very visible.&lt;/p&gt;
&lt;p&gt;The lock settings keep backend load and latency bounded.  &lt;code&gt;proxy_cache_lock_timeout 1s&lt;/code&gt;  bounds how long queued requests wait behind the lock. If the timeout expires, they go to the upstream directly, but their responses are not stored in the cache, which prevents a runaway thundering herd from clogging the cache fill path.  &lt;code&gt;proxy_cache_lock_age 5s&lt;/code&gt;  prevents one slow cache-populating request from monopolizing the fill path forever; if the request holding the lock has not completed after 5 seconds, nginx may let another request reach the upstream to retry.&lt;/p&gt;
&lt;p&gt;I have currently left  &lt;code&gt;proxy_cache_use_stale&lt;/code&gt;  off on this location while I am still validating the deployment. This is a deliberate debugging stance, not a permanent choice. Stale-while-revalidate is useful in production, but during rollout it can hide upstream issues while I am trying to understand the system. Once the behaviour is stable, the production version will be:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
&lt;/code&gt;&lt;/pre&gt;

&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;location ^~ /media_proxy/ {
    proxy_cache mastodon_cache;
    proxy_cache_key &amp;quot;$scheme$host$request_uri&amp;quot;;

    proxy_cache_valid 200 10m;
    proxy_cache_valid 301 302 10m;
    proxy_cache_valid 404 1m;

    proxy_ignore_headers Cache-Control Expires Vary;

    proxy_cache_lock on;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_background_update on;

    proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
    proxy_no_cache     $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;

    proxy_pass http://$custom_upstream;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Mastodon's  &lt;code&gt;/media_proxy/&lt;/code&gt;  fetches remote media so clients do not leak their IP address to remote servers. The response is the same regardless of  &lt;code&gt;Accept&lt;/code&gt;, so the cache key intentionally omits the variant. Splitting media proxy responses across  &lt;code&gt;html&lt;/code&gt;,  &lt;code&gt;json&lt;/code&gt;,  &lt;code&gt;activitypub&lt;/code&gt;, and  &lt;code&gt;default&lt;/code&gt;  buckets would only waste storage.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;proxy_ignore_headers Cache-Control Expires Vary&lt;/code&gt;  is deliberate here. Mastodon may emit conservative cache headers, or none at all, and I want the proxy to enforce a short local 10-minute policy regardless of what the backend says.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Set-Cookie&lt;/code&gt;  is not in the ignore list. nginx's default refusal to cache responses carrying  &lt;code&gt;Set-Cookie&lt;/code&gt;  still applies, and I want it to. It is a safety net I do not want to disable just to win a few cache hits.&lt;/p&gt;
&lt;p&gt;The  &lt;code&gt;^~&lt;/code&gt;  prefix is a small useful detail. Once this location matches, nginx stops evaluating regex locations. Media proxy traffic can be heavy, and skipping further regex matching is a tiny but free win.&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;location ~ ^/(users|ap/users)/[^/]+/(followers|following) {
    proxy_pass http://$custom_upstream;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This one is a pure proxy, no cache. I want to be explicit that this is a decision, not an omission.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;/users/&amp;lt;name&amp;gt;/followers&lt;/code&gt;  and  &lt;code&gt;/users/&amp;lt;name&amp;gt;/following&lt;/code&gt;  are pagination-heavy, change frequently as people follow and unfollow, and are queried by federation crawlers in ways that would make the cache key proliferate through pages and cursors. The likely hit ratio is poor, the risk of serving stale social graph data is non-trivial, and the cost of caching them - in storage and in mental overhead - is not worth it.&lt;/p&gt;
&lt;p&gt;If a remote instance starts hammering these endpoints, the right answer is rate limiting with  &lt;code&gt;limit_req_zone&lt;/code&gt;, not retrofitting cache as a rate limiter.&lt;/p&gt;
&lt;h3&gt;Default location: the microcache and streaming without cache&lt;/h3&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;location / {
    proxy_cache mastodon_cache;
    proxy_cache_key &amp;quot;$scheme$host$request_uri|accept=$mastodon_cache_variant&amp;quot;;
    proxy_ignore_headers Vary;

    proxy_cache_valid 200 10s;
    proxy_cache_valid 301 302 1m;
    proxy_cache_valid 404 10s;

    proxy_cache_lock on;
    proxy_cache_lock_timeout 5s;

    proxy_cache_bypass $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;
    proxy_no_cache     $skip_cache_method $skip_cache_auth $skip_cache_cookie $skip_cache_uri;

    proxy_next_upstream error timeout http_502 http_503 http_504;
    proxy_next_upstream_tries 2;

    proxy_pass http://$custom_upstream;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Everything not matched by a more specific location falls here: profiles, individual statuses, the about page, the public timeline, and many ActivityPub object fetches.&lt;/p&gt;
&lt;p&gt;The TTL is only 10 seconds for 200 responses. That is enough to deduplicate the wave of requests when a popular toot gets boosted or linked from elsewhere, without making the page feel stale to a human visitor.&lt;/p&gt;
&lt;p&gt;It is worth being honest that short TTLs still cost CPU. A 10-second microcache on a sustained-traffic URL means the backend regenerates the entry six times per minute. That is vastly better than serving every request from Rails, but it is not free. If your backend cannot comfortably handle that, raise the TTL, or enable stale-while-revalidate on these dynamic paths.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;proxy_next_upstream&lt;/code&gt;  with  &lt;code&gt;proxy_next_upstream_tries 2&lt;/code&gt;  is the failover trigger. If the primary returns 502, 503, 504, or times out, nginx retries on the backup. The chain is capped at two attempts so a sick upstream cannot hold the request indefinitely.&lt;/p&gt;
&lt;p&gt;At the  &lt;code&gt;http&lt;/code&gt;  level:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;map $http_upgrade $connection_upgrade {
    default upgrade;
    &amp;quot;&amp;quot;      close;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In the server block:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;location /api/v1/streaming {
    proxy_buffering off;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    proxy_pass http://$custom_upstream;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Streaming is a WebSocket and SSE-style endpoint. Buffering must be off, otherwise the proxy may hold messages while waiting for buffers to fill. The  &lt;code&gt;Upgrade&lt;/code&gt;  and  &lt;code&gt;Connection&lt;/code&gt;  headers are driven by  &lt;code&gt;$connection_upgrade&lt;/code&gt;, which is  &lt;code&gt;upgrade&lt;/code&gt;only when the client actually sent an  &lt;code&gt;Upgrade&lt;/code&gt;  header. That way a non-WebSocket request to the same path does not get its  &lt;code&gt;Connection&lt;/code&gt;  header mangled.&lt;/p&gt;
&lt;p&gt;The hour-long read and send timeouts allow long-lived streams to stay open through quiet periods.&lt;/p&gt;
&lt;p&gt;There is no cache here. Streaming is not a cacheable workload, and trying to make it one is one of those ideas that sounds clever for about thirty seconds.&lt;/p&gt;
&lt;h2&gt;Upstream and failover&lt;/h2&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;upstream mastodonbsdcafe {
    server 192.168.123.33  max_fails=3 fail_timeout=30s;
    server 192.168.122.133 backup;
    keepalive 64;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The primary backend is on another VPS; the backup is in a jail next to the reverse proxy. After three consecutive failures, the primary is marked down for 30 seconds. Traffic flips to the backup, then nginx retries the primary after the window.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;keepalive 64&lt;/code&gt;  holds up to 64 idle TCP connections to the upstream per worker. On a busy instance, this saves real handshake overhead, but only if the proxied connection can actually stay open. That is why the shared proxy settings include  &lt;code&gt;proxy_http_version 1.1&lt;/code&gt;  and  &lt;code&gt;proxy_set_header Connection ""&lt;/code&gt;. Without those, upstream keepalive does much less than it looks like it should.&lt;/p&gt;
&lt;p&gt;I also use an indirection layer:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;map $remote_addr $custom_upstream {
    default mastodonbsdcafe;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Today everything defaults to the main upstream group. The map exists so that specific client IPs can be pinned to a specific upstream when I am debugging, or so an admin connection can be routed to the backup while the primary is being tested. It costs nothing to have it sitting there, and it has saved me time more than once.&lt;/p&gt;
&lt;h2&gt;What I log and why&lt;/h2&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;log_format detailed '$remote_addr - $remote_user [$time_local] '
                    '&amp;quot;$request&amp;quot; $status $body_bytes_sent '
                    '&amp;quot;$http_referer&amp;quot; &amp;quot;$http_user_agent&amp;quot; '
                    'rt=$request_time '
                    'uct=$upstream_connect_time '
                    'uht=$upstream_header_time '
                    'urt=$upstream_response_time '
                    'us=$upstream_status '
                    'ua=$upstream_addr '
                    'cache=$upstream_cache_status '
                    'variant=$mastodon_cache_variant';

access_log /var/log/nginx/access.mastodon.bsd.cafe.log detailed;

add_header X-Cache-Status  $upstream_cache_status always;
add_header X-Cache-Variant $mastodon_cache_variant always;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This log format is purpose-built for the cache layer. For each request it records total request time, upstream connect time, upstream header time, upstream response time, upstream status, which backend served the request, cache status, and which content-negotiation variant was selected.&lt;/p&gt;
&lt;p&gt;The cache status is one of the values nginx exposes through  &lt;code&gt;$upstream_cache_status&lt;/code&gt;:  &lt;code&gt;HIT&lt;/code&gt;,  &lt;code&gt;MISS&lt;/code&gt;,  &lt;code&gt;BYPASS&lt;/code&gt;,  &lt;code&gt;EXPIRED&lt;/code&gt;,  &lt;code&gt;STALE&lt;/code&gt;,  &lt;code&gt;UPDATING&lt;/code&gt;, or  &lt;code&gt;REVALIDATED&lt;/code&gt;. The response headers expose the same information to the client, which makes it trivial to verify behaviour with  &lt;code&gt;curl -I&lt;/code&gt;  or browser dev tools.&lt;/p&gt;
&lt;p&gt;The  &lt;code&gt;always&lt;/code&gt;  qualifier matters. Without it, nginx only adds these headers to a subset of responses, so a 502 from the backend might arrive without the diagnostic headers you need most. I want them on every response, no exceptions.&lt;/p&gt;
&lt;p&gt;There is also a small operational detail I find pleasant: a custom 502 page.&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;error_page 502 /502.html;
location = /502.html {
    root /usr/local/www/mastodon_errors;
    internal;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;It is not part of the cache strategy, but it makes backend hiccups less ugly. And I block some abusive user agents with  &lt;code&gt;444&lt;/code&gt;, which closes the connection without sending any response at all:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;if ($http_user_agent ~* &amp;quot;bytespider&amp;quot;) {
    return 444;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This is not a general bot strategy. It is just a cheap refusal path for traffic I know I do not want.&lt;/p&gt;
&lt;h2&gt;How I check it actually works&lt;/h2&gt;
&lt;p&gt;A configuration that I cannot verify is a configuration I do not trust. Here is the short set of commands I keep in a paste buffer for this proxy.&lt;/p&gt;
&lt;p&gt;The first verification is variant separation. Three requests to the same URL with different  &lt;code&gt;Accept&lt;/code&gt;  headers should produce three independent cache entries:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;for v in 'text/html' \
         'application/activity+json' \
         'application/ld+json; profile=&amp;quot;https://www.w3.org/ns/activitystreams&amp;quot;'; do
  printf '%-75s -&amp;gt; ' &amp;quot;$v&amp;quot;
  curl -s -o /dev/null -D - -H &amp;quot;Accept: $v&amp;quot; \
    https://mastodon.bsd.cafe/@someuser/123456789 \
    | awk '/^[Xx]-[Cc]ache/ { printf &amp;quot;%s &amp;quot;, $0 } END { print &amp;quot;&amp;quot; }'
done
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;On the first pass, every variant should be a  &lt;code&gt;MISS&lt;/code&gt;. On the second pass, every variant should be a  &lt;code&gt;HIT&lt;/code&gt;, with  &lt;code&gt;X-Cache-Variant&lt;/code&gt;showing the expected bucket.&lt;/p&gt;
&lt;p&gt;The second verification is that cookies and  &lt;code&gt;Authorization&lt;/code&gt;  always trigger  &lt;code&gt;BYPASS&lt;/code&gt;:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;curl -I -H 'Cookie: _mastodon_session=test' \
  https://mastodon.bsd.cafe/@someuser

curl -I -H 'Authorization: Bearer fake' \
  https://mastodon.bsd.cafe/api/v1/timelines/home
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Both should return  &lt;code&gt;X-Cache-Status: BYPASS&lt;/code&gt;. If they do not, the skip-cache rules are wrong, and the entire setup is unsafe.&lt;/p&gt;
&lt;p&gt;If you intend to enable  &lt;code&gt;AUTHORIZED_FETCH&lt;/code&gt;, the third verification is for signed GETs. A quick synthetic check that the nginx map fires correctly:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;curl -I -H 'Signature: fake' \
       -H 'Accept: application/activity+json' \
       https://mastodon.bsd.cafe/users/someuser
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If you added  &lt;code&gt;$skip_cache_signature&lt;/code&gt;, the result should be  &lt;code&gt;X-Cache-Status: BYPASS&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Finally, the logs themselves tell me how the cache is performing in production. Cache status distribution:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;awk '{
  for (i = 1; i &amp;lt;= NF; i++)
    if ($i ~ /^cache=/) c[$i]++
}
END {
  for (k in c) print k, c[k]
}' /var/log/nginx/access.mastodon.bsd.cafe.log
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A healthy instance shows  &lt;code&gt;cache=HIT&lt;/code&gt;  and  &lt;code&gt;cache=BYPASS&lt;/code&gt;  doing most of the work, with  &lt;code&gt;cache=MISS&lt;/code&gt;  accounting for cold paths and short-TTL refreshes. The same trick works for the variant distribution:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;awk '{
  for (i = 1; i &amp;lt;= NF; i++)
    if ($i ~ /^variant=/) v[$i]++
}
END {
  for (k in v) print k, v[k]
}' /var/log/nginx/access.mastodon.bsd.cafe.log
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This tells me what my traffic actually looks like. A federation-heavy instance shows a lot of  &lt;code&gt;activitypub&lt;/code&gt;. An instance with many human visitors shows more  &lt;code&gt;html&lt;/code&gt;. On  &lt;code&gt;mastodon.bsd.cafe&lt;/code&gt;  the balance shifts depending on what is happening in the wider Fediverse on any given day.&lt;/p&gt;
&lt;h2&gt;Caveats worth being honest about&lt;/h2&gt;
&lt;p&gt;I do not like presenting configurations as magic, so I want to be explicit about the conditions under which this one is appropriate.&lt;/p&gt;
&lt;p&gt;Short TTLs cost CPU. A 10-second microcache on a sustained-traffic URL means six backend regenerations per minute. That is much better than no cache, but it is not free. If the backend cannot comfortably handle that, raise the TTL or enable stale-while-revalidate on the dynamic paths.&lt;/p&gt;
&lt;p&gt;Dynamic stale-while-revalidate is powerful but it hides problems. I currently keep  &lt;code&gt;proxy_cache_use_stale&lt;/code&gt;  off on the dynamic locations because I am still validating behaviour. In steady-state production, stale-while-revalidate is usually the right choice. During rollout, it can quietly hide upstream errors and make debugging harder. Be honest with yourself about which mode you are in.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AUTHORIZED_FETCH&lt;/code&gt;  changes the threat model. With secure mode disabled, public ActivityPub GET responses are safe to cache as public content, provided your cache key handles content negotiation correctly. With secure mode enabled, ActivityPub responses can become actor-dependent. At that point you must either bypass cache for signed GETs or include the signing actor in the key. The latter usually destroys the hit ratio, so bypassing is the practical answer.&lt;/p&gt;
&lt;p&gt;The variant map is a compromise. It covers  &lt;code&gt;application/activity+json&lt;/code&gt;,  &lt;code&gt;application/ld+json&lt;/code&gt;,  &lt;code&gt;application/json&lt;/code&gt;, and  &lt;code&gt;text/html&lt;/code&gt;. Everything else falls into the  &lt;code&gt;default&lt;/code&gt;  bucket. That is intentional, but the default bucket is still a bucket. If you discover a real client type that matters on your instance, add it explicitly.&lt;/p&gt;
&lt;p&gt;Ignoring  &lt;code&gt;Vary&lt;/code&gt;  is a responsibility.  &lt;code&gt;proxy_ignore_headers Vary&lt;/code&gt;  is not magic; it tells nginx to stop protecting you based on upstream  &lt;code&gt;Vary&lt;/code&gt;. That is fine only if your own cache key and request normalization cover every dimension  &lt;code&gt;Vary&lt;/code&gt;  was protecting. For this configuration that means normalizing  &lt;code&gt;Accept&lt;/code&gt;  into a variant, avoiding backend  &lt;code&gt;Accept-Encoding&lt;/code&gt;variation, never caching cookies or authorization, and never caching signed GETs if secure mode is enabled.&lt;/p&gt;
&lt;p&gt;Followers and following are uncached on purpose. They are pagination-heavy and change frequently. Caching them would create many low-value entries with questionable freshness. If a remote instance hammers these endpoints, use  &lt;code&gt;limit_req_zone&lt;/code&gt;. Do not retrofit cache as a rate limiter.&lt;/p&gt;
&lt;p&gt;Signed-URL redirects require shorter TTLs. Caching 302s is useful when redirects are stable. It is dangerous when redirects point to short-lived signed URLs. If your media storage returns presigned URLs, your nginx redirect TTL must be shorter than the URL lifetime.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;Set-Cookie&lt;/code&gt;  must remain special. Do not add  &lt;code&gt;Set-Cookie&lt;/code&gt;  to  &lt;code&gt;proxy_ignore_headers&lt;/code&gt;  unless you are absolutely sure the location cannot produce user-specific responses. nginx's default refusal to cache  &lt;code&gt;Set-Cookie&lt;/code&gt;  responses is a safety net. Keep it.&lt;/p&gt;
&lt;p&gt;A good configuration is a written form of the assumptions behind a service. When the assumptions change, the configuration must change too.&lt;/p&gt;
&lt;p&gt;There is no single brilliant directive in this configuration. The trick is combining long TTLs for immutable assets, medium TTLs for media, tiny TTLs for dynamic public pages, cache locking for thundering-herd protection, strict bypass rules for private or actor-dependent traffic, a normalized content-negotiation key, and enough logging to prove the system is doing what I think it is.&lt;/p&gt;
&lt;p&gt;What this layer buys me, in one sentence: fewer requests reach Puma and Rails.&lt;/p&gt;
&lt;p&gt;That is the metric I care about. Mastodon is not slow, but it is heavy, and the bigger the instance grows the more it benefits from a layer in front that quietly absorbs the work that does not need to be done by the application. A reverse proxy that caches Mastodon safely has to remember, with every request, that the same URL might mean three different things to three different clients. Once it does, even a very short microcache can remove a surprising amount of load without changing the user-visible behaviour of the instance.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Fri, 05 Jun 2026 08:44:00 +0000</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2026/06/05/aggressive_caching_for_a_mastodon_reverse_proxy/</guid><category>mastodon</category><category>nginx</category><category>server</category><category>networking</category><category>hosting</category><category>fediverse</category><category>ownyourdata</category><category>snac2</category><category>web</category><category>social</category></item><item><title>FediMeteo, HAProxy, and the art of not wasting snac threads</title><link>https://it-notes.dragas.net/2026/05/18/fedimeteo-haproxy-and-the-art-of-not-wasting-snac-threads/</link><description>&lt;p&gt;&lt;img src="https://unsplash.com/photos/ZVhm6rEKEX8/download?ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzQwNTEzNjE5fA&amp;force=true&amp;w=640" alt="FediMeteo, HAProxy, and the art of not wasting snac threads"&gt;&lt;/p&gt;&lt;p&gt;When I wrote about &lt;a href="https://it-notes.dragas.net/2025/02/26/fedimeteo-how-a-tiny-freebsd-vps-became-a-global-weather-service-for-thousands/"&gt;FediMeteo&lt;/a&gt; for the first time, I told the story from the beginning: the idea born almost by chance while checking the weather for a holiday, the memory of my grandfather, who for years had been my personal meteorologist, the decision to build something small and useful, and then the surprise of seeing people actually use it. What began as a personal experiment quickly became a small global service, still running with the same philosophy: FreeBSD, jails, simple scripts, snac, text, emoji, and a lot of small pieces doing their work quietly.&lt;/p&gt;
&lt;p&gt;That article was mostly about the birth and growth of the project. This one is about one of the less romantic parts of the same story, although I have to admit that I find a certain beauty in it too: keeping the service light as it grows.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://fedimeteo.com"&gt;FediMeteo&lt;/a&gt; is still intentionally simple from the outside. A homepage, some numbers, a list of countries, and many ActivityPub accounts publishing weather forecasts. The posts are text and emoji. There is no JavaScript requirement to read the pages, no heavy frontend, no unnecessary media attached to every forecast, and no dynamic homepage recalculated at every visit just to show the same numbers. This is not accidental. It is the way I wanted the service to behave from the beginning.&lt;/p&gt;
&lt;p&gt;But the more the service is used, the more the small details matter. A request that looks harmless when there are ten followers may become a repeated request when there are thousands of followers, remote instances, crawlers, previews, and other servers fetching the same public objects. In the Fediverse, the same small thing can be asked many times by many different places, each one with a perfectly legitimate reason. The backend doesn't care: it just needs to deal with the requests.&lt;/p&gt;
&lt;p&gt;And in FediMeteo, the backend is &lt;a href="https://codeberg.org/grunfink/snac2"&gt;snac&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;I like snac very much precisely because it is small, clear, and efficient. It is not a giant application that tries to be everything. It does a focused job and does it well. But this also means that I want to respect its shape. I do not want to waste its threads on work that the reverse proxy can safely do. A snac thread serving the same public avatar again and again is not a tragedy, but it is still a waste. A snac thread answering the same public ActivityPub object several times in the same minute is doing real work, but often not necessary work.&lt;/p&gt;
&lt;p&gt;This is the reason behind the &lt;a href="https://www.haproxy.org"&gt;HAProxy&lt;/a&gt; tuning I am currently using in front of FediMeteo.&lt;/p&gt;
&lt;p&gt;It is not about making the configuration look clever. It is about keeping snac quiet.&lt;/p&gt;
&lt;h2&gt;A continuation of the same idea&lt;/h2&gt;
&lt;p&gt;I had already explored the same problem with snac and nginx in two previous posts:  &lt;a href="https://it-notes.dragas.net/2025/01/29/improving-snac-performance-with-nginx-proxy-cache/"&gt;Improving snac Performance with Nginx Proxy Cache&lt;/a&gt;  and  &lt;a href="https://it-notes.dragas.net/2025/02/08/caching-snac-proxied-media-with-nginx/"&gt;Caching snac Proxied Media with Nginx&lt;/a&gt;. In both cases, the idea was that the reverse proxy should absorb repeated public requests instead of letting them consume snac resources.&lt;/p&gt;
&lt;p&gt;This is especially important because snac uses a limited number of threads. I like that. Limits are healthy. They force us to understand what the service is doing, and they prevent a small program from pretending to be an infinite resource. But limits also make waste visible. If a few threads are busy serving files that could have been served from cache, those threads are not available for something more useful.&lt;/p&gt;
&lt;p&gt;With FediMeteo the implementation is different because the reverse proxy is HAProxy, but the reasoning is the same. I have many small snac instances, each one in its own FreeBSD (&lt;a href="https://github.com/BastilleBSD/bastille"&gt;Bastille&lt;/a&gt;) jail, and one public entry point that has to route, terminate TLS, compress, cache, and generally remove as much repetitive work as possible from the backends.&lt;/p&gt;
&lt;p&gt;This is, in a way, the natural continuation of the original FediMeteo design. In the first article I wrote that I wanted to manage everything according to the Unix philosophy: small pieces working together. This is another piece of that same puzzle. HAProxy does the edge work. snac does the ActivityPub work. Scripts generate forecasts. cron launches updates. ZFS gives me snapshots. FreeBSD jails keep countries separated. Nothing is particularly heroic by itself, but the whole system becomes pleasant because each part has a clear responsibility.&lt;/p&gt;
&lt;h2&gt;Why there is almost no media&lt;/h2&gt;
&lt;p&gt;Before talking about HAProxy, it is worth mentioning one of the most important optimizations, which is not in the proxy configuration at all.&lt;/p&gt;
&lt;p&gt;FediMeteo does not use media in its forecasts.&lt;/p&gt;
&lt;p&gt;No images attached to the posts, no generated weather cards, no maps for each city, no decorative banners. The forecasts are text and emoji. This was a deliberate decision. Weather information does not become more useful just because it is put inside an image, and every media file used by the service would become something to store, serve, cache, federate, expire, back up, and occasionally debug.&lt;/p&gt;
&lt;p&gt;Text and emoji are enough. They are accessible, light, readable in text browsers, friendly to timelines, and understandable even when someone does not know the local language perfectly. This was one of the original design principles of FediMeteo, and it also helps the infrastructure. Less media means less work, fewer cache entries, fewer repeated fetches, fewer surprises.&lt;/p&gt;
&lt;p&gt;There is one exception: the avatar.&lt;/p&gt;
&lt;p&gt;All FediMeteo accounts use the same avatar, and this is also intentional. I could have used a different avatar for each country, or for each city, or created something visually richer. It would have been nicer in some screenshots, perhaps. It would also have been operationally worse.&lt;/p&gt;
&lt;p&gt;With one shared avatar, the reverse proxy has one very useful object to cache. It is public, identical for everyone, small, requested often, and therefore almost always hot in cache. HAProxy can serve it directly instead of asking each snac instance to return the same file. Since avatars are requested by remote instances, browsers, profile previews, and all sorts of federation-related fetches, this single decision removes a surprising amount of pointless backend traffic.&lt;/p&gt;
&lt;p&gt;So the avatar is not only a visual identity. It is part of the architecture.&lt;/p&gt;
&lt;p&gt;This is the kind of optimization I like most, because it starts before the software. It starts with deciding not to create a problem.&lt;/p&gt;
&lt;h2&gt;The homepage is static because it can be static&lt;/h2&gt;
&lt;p&gt;The main homepage follows the same logic.&lt;/p&gt;
&lt;p&gt;It is a static HTML page generated from a template. Once per hour, a cron script updates the numbers and statistics. It counts the data I want to show, regenerates the page, and then the page remains static until the next run.&lt;/p&gt;
&lt;p&gt;This is not because I cannot make a dynamic page. It is because I do not need one. Boring is good.&lt;/p&gt;
&lt;p&gt;The homepage does not need to query all the country instances on every visit. It does not need a database request for each user who opens it. It does not need to ask snac anything in real time. The numbers are useful, but they do not need to be updated every second. Once per hour is enough, and it also fits the spirit of the whole project: do the work when it is needed, then serve the result cheaply.&lt;/p&gt;
&lt;p&gt;I have seen too many small services become heavy because the first implementation was convenient rather than appropriate. A cron job and a template are not fashionable, but they are often exactly what a page like this needs.&lt;/p&gt;
&lt;h2&gt;Many countries, one entry point&lt;/h2&gt;
&lt;p&gt;FediMeteo is made of many country instances. Each one runs in its own jail and listens on its own internal address and port. From the outside, however, they all live under the same domain structure:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-text"&gt;fedimeteo.com
www.fedimeteo.com
it.fedimeteo.com
uk.fedimeteo.com
jp.fedimeteo.com
us.fedimeteo.com
usa.fedimeteo.com
can.fedimeteo.com
canada.fedimeteo.com
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And many more.&lt;/p&gt;
&lt;p&gt;At the beginning, it is always tempting to write one ACL after another in the HAProxy frontend. It is quick, it is explicit, and for five hostnames it is perfectly fine. But FediMeteo did not remain at five hostnames. As countries and aliases grew, a long chain of ACLs would have turned the frontend into a list of names instead of a description of how the proxy behaves.&lt;/p&gt;
&lt;p&gt;So I moved the hostname to backend mapping into a map file:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-text"&gt;fedimeteo.com        backend_fedimeteo
www.fedimeteo.com    backend_fedimeteo
it.fedimeteo.com     backend_it
uk.fedimeteo.com     backend_uk
jp.fedimeteo.com     backend_jp
us.fedimeteo.com     backend_us
usa.fedimeteo.com    backend_us
can.fedimeteo.com    backend_ca
canada.fedimeteo.com backend_ca
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The frontend then needs only one rule:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;use_backend %[req.hdr(host),field(1,:),lower,map(/usr/local/etc/fedimeteo.map,backend_fedimeteo)]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This reads the  &lt;code&gt;Host&lt;/code&gt;  header, removes the port if present, lowercases the result, and looks it up in  &lt;code&gt;/usr/local/etc/fedimeteo.map&lt;/code&gt;. If nothing matches, it falls back to the main FediMeteo backend.&lt;/p&gt;
&lt;p&gt;I like this because it keeps the configuration honest. The frontend contains the policy. The map contains the data. Adding a country means adding an entry to the map and defining a backend. I do not need to make the frontend more complicated every time the service grows.&lt;/p&gt;
&lt;h2&gt;Backends as small compartments&lt;/h2&gt;
&lt;p&gt;The country backends are deliberately plain:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;backend backend_it
    mode http
    http-reuse safe
    server srv1 10.0.0.2:8001 maxconn 30

backend backend_uk
    mode http
    http-reuse safe
    server srv1 10.0.0.7:8001 maxconn 30

backend backend_jp
    mode http
    http-reuse safe
    server srv1 10.0.0.32:8001 maxconn 30
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;One backend, one jail, one snac instance. This is exactly the same organizational principle as the rest of the project. If I need to reason about Italy, I look at the Italian jail. If I need to reason about the United Kingdom, I look at the UK jail. If one day I need to move a country elsewhere, the separation is already there.&lt;/p&gt;
&lt;p&gt;The  &lt;code&gt;maxconn 30&lt;/code&gt;  value is not a magic number. It is a ceiling. I want each small backend to have a visible limit in front of it. If something starts hammering a country instance, I prefer the pressure to appear at the HAProxy layer instead of becoming unlimited concurrent work inside snac.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;http-reuse safe&lt;/code&gt;  lets HAProxy reuse backend connections where appropriate. This is another small reduction in unnecessary work. Opening connections repeatedly is not the biggest problem in the world, but avoiding it is still better, especially when many small services sit behind the same proxy.&lt;/p&gt;
&lt;h2&gt;The front door&lt;/h2&gt;
&lt;p&gt;The HTTPS frontend listens on IPv4 and IPv6 and offers both HTTP/2 and HTTP/1.1:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;frontend https_in
    bind :::443 v4v6 ssl crt /usr/local/etc/certs/ alpn h2,http/1.1
    mode http
    option http-keep-alive
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;TLS defaults are set globally:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-bind-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Port 80 only redirects to HTTPS, except for Let's Encrypt challenges:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;acl letsencrypt-acl path_beg /.well-known/acme-challenge/
http-request redirect scheme https code 301 unless letsencrypt-acl
use_backend letsencrypt-backend if letsencrypt-acl
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In the HTTPS frontend I also set the usual forwarding headers:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;http-request set-header X-Real-IP %[src]
http-request set-header X-Forwarded-Proto https
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And I add HSTS:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;http-response set-header Strict-Transport-Security &amp;quot;max-age=31536000; includeSubDomains; preload&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;None of this is unusual, and that is fine. The interesting parts of an infrastructure are not always the parts that should be unusual.&lt;/p&gt;
&lt;h2&gt;Two caches, because the requests are different&lt;/h2&gt;
&lt;p&gt;The HAProxy configuration defines two caches:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;cache mediacache
  total-max-size 128
  max-object-size 10000000
  max-age 3600
  process-vary on
  max-secondary-entries 12

cache jsoncache
  total-max-size 16
  max-object-size 1000000
  max-age 60
  process-vary on
  max-secondary-entries 12
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I keep media and ActivityPub JSON separate because they are not the same kind of traffic.&lt;/p&gt;
&lt;p&gt;The media cache is larger and has a longer maximum age. In FediMeteo, this mostly means the shared avatar and a few static-looking objects. Since there is intentionally almost no media, the important cached object is requested very often and remains warm.&lt;/p&gt;
&lt;p&gt;The JSON cache is smaller and short-lived. It is there for public ActivityPub GET requests, not to store federation state forever. A 60 second cache is enough to collapse many repeated requests that arrive close together in time, without pretending that ActivityPub responses should be treated like immutable files.&lt;/p&gt;
&lt;p&gt;This distinction is important. Caching is not one decision. It is a set of small decisions about what a response means, who can see it, how often it changes, and what happens if it is served again.&lt;/p&gt;
&lt;h2&gt;Recognizing media&lt;/h2&gt;
&lt;p&gt;For media, the ACL is based on file extensions:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;acl is_media path_end -i .jpg .jpeg .png .gif .webp .svg .ico .mp4 .webm .mp3 .ogg .wav .flac .mov .avi .mkv .m4v
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then I store the result in a transaction variable:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;http-request set-var(txn.is_media) bool(true) if is_media
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The cache lookup is straightforward:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;http-request cache-use mediacache if { var(txn.is_media) -m bool true }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And on the response side:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;http-response set-header Cache-Control &amp;quot;max-age=3600, public&amp;quot; if { var(txn.is_media) -m bool true }
http-response del-header Set-Cookie if { var(txn.is_media) -m bool true }
http-response del-header Vary if { var(txn.is_media) -m bool true }
http-response cache-store mediacache if { var(txn.is_media) -m bool true }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The  &lt;code&gt;Cache-Control&lt;/code&gt;  header makes the intent explicit.  &lt;code&gt;Set-Cookie&lt;/code&gt;  is removed because a public media object should not carry session information.  &lt;code&gt;Vary&lt;/code&gt;  is removed because I do not want the same avatar to fragment into many cache entries because of harmless header differences.&lt;/p&gt;
&lt;p&gt;This is aggressive only if removed from its context. In this service, with this media policy, it is a reasonable choice. FediMeteo is not serving private media under these paths. It is mostly serving the same public avatar over and over.&lt;/p&gt;
&lt;p&gt;For the same reason, I clean the request before it reaches the backend:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;http-request del-header Authorization if { var(txn.is_media) -m bool true }
http-request del-header Cookie        if { var(txn.is_media) -m bool true }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I would not do this globally. I do it after deciding that the request is media. Scope is what makes these rules safe.&lt;/p&gt;
&lt;p&gt;The result is exactly what I want: the shared avatar becomes an almost perfect cache object. Small, public, repeatedly requested, and served by HAProxy instead of snac.&lt;/p&gt;
&lt;h2&gt;ActivityPub JSON microcaching&lt;/h2&gt;
&lt;p&gt;The ActivityPub side starts from the  &lt;code&gt;Accept&lt;/code&gt;  header:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;acl is_ap_json   req.hdr(Accept),lower -m sub application/activity+json
acl is_ap_ldjson req.hdr(Accept),lower -m sub application/ld+json
acl is_outbox    path_end /outbox
acl is_get       method GET
acl has_auth     req.hdr(Authorization) -m found
acl has_cookie   req.hdr(Cookie) -m found
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This part matters because ActivityPub uses content negotiation. The same path may return HTML to a browser and JSON to a remote instance. If the proxy pretends that a URL is always one thing, it will eventually cache the wrong representation.&lt;/p&gt;
&lt;p&gt;So I only mark public ActivityPub GET requests as cacheable:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;http-request set-var(txn.is_activitypub) bool(true) if is_get !is_outbox is_ap_json !has_auth !has_cookie
http-request set-var(txn.is_activitypub) bool(true) if is_get !is_outbox is_ap_ldjson !has_auth !has_cookie
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;There are several decisions here, all important.&lt;/p&gt;
&lt;p&gt;It must be a  &lt;code&gt;GET&lt;/code&gt;, because I am not caching deliveries or anything that changes state. It must not be  &lt;code&gt;/outbox&lt;/code&gt;, because outbox collections are not the traffic I want to cache here. It must not have  &lt;code&gt;Authorization&lt;/code&gt;, and it must not have cookies, because authenticated or user-specific requests do not belong in a shared public cache.&lt;/p&gt;
&lt;p&gt;Then the cache can be used and populated:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;http-request cache-use jsoncache if { var(txn.is_activitypub) -m bool true }

http-response set-header Cache-Control &amp;quot;max-age=60, public&amp;quot; if { var(txn.is_activitypub) -m bool true }
http-response cache-store jsoncache if { var(txn.is_activitypub) -m bool true }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Sixty seconds is short, but useful. Federation often creates small clusters of identical requests. A remote server fetches an actor, another fetches the same actor, something asks for the same object, something retries. I do not need to cache these responses for hours. I only need HAProxy to answer the second and third identical request during the same small burst.&lt;/p&gt;
&lt;p&gt;This is microcaching in the most practical sense. It reduces repeated work without changing the nature of the service.&lt;/p&gt;
&lt;h2&gt;Static media paths&lt;/h2&gt;
&lt;p&gt;There is also a rule for static paths:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;acl is_short_path path_reg ^/[^/]+/s/
http-request cache-use mediacache if is_short_path
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This comes from the same observation that led me to cache snac media with nginx. snac uses static media paths, and those paths often represent the kind of public, repeatable traffic that should not consume backend threads if the proxy can serve it. I call them "short", not because they are, but because the first time I saw them, I thought the 's' stood for "short", not "static". The name just stuck.&lt;/p&gt;
&lt;p&gt;In FediMeteo this is less central than on a normal social instance, because I deliberately do not use media except for the avatar and basic static objects. Still, the rule fits the general policy: let HAProxy handle repeatable edge work, and let snac spend its threads where they are actually needed.&lt;/p&gt;
&lt;h2&gt;&lt;code&gt;Vary&lt;/code&gt;, but not without limits&lt;/h2&gt;
&lt;p&gt;Both caches have:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;process-vary on
max-secondary-entries 12
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I want HAProxy to process  &lt;code&gt;Vary&lt;/code&gt;, because content negotiation is real, especially when ActivityPub is involved. But I also want variation to be bounded. If every slightly different header creates another cache entry, the cache becomes a complicated way to miss.&lt;/p&gt;
&lt;p&gt;For media, I remove  &lt;code&gt;Vary&lt;/code&gt;  before storing the response. A shared avatar does not need to vary by  &lt;code&gt;Accept&lt;/code&gt;. For ActivityPub JSON, I am more careful because the representation matters.&lt;/p&gt;
&lt;p&gt;Again, the important thing is not the number itself. It is the decision to make variation explicit and limited.&lt;/p&gt;
&lt;h2&gt;Seeing whether it works&lt;/h2&gt;
&lt;p&gt;During rollout, I like to expose a very small diagnostic header:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;http-response set-header X-Cache-Status HIT if !{ srv_id -m found }
http-response set-header X-Cache-Status MISS if { srv_id -m found }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This is intentionally simple. If HAProxy selected a backend server, I call it a miss. If no backend server was selected, the response came from cache, so I call it a hit. It is not a complete observability system, but it is enough to answer the first question I usually have after changing a cache rule.&lt;/p&gt;
&lt;p&gt;Did this request reach snac?&lt;/p&gt;
&lt;p&gt;A test can be as simple as:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;curl -I https://it.fedimeteo.com/path/to/avatar.png
curl -I https://it.fedimeteo.com/path/to/avatar.png
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The second request should be a hit.&lt;/p&gt;
&lt;p&gt;For ActivityPub JSON, the test must use the right  &lt;code&gt;Accept&lt;/code&gt;  header:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;curl -I \
  -H 'Accept: application/activity+json' \
  https://it.fedimeteo.com/some/activitypub/object
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And I also want to verify that cookies and authorization prevent public caching:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;curl -I \
  -H 'Cookie: test=value' \
  -H 'Accept: application/activity+json' \
  https://it.fedimeteo.com/some/activitypub/object

curl -I \
  -H 'Authorization: Bearer fake' \
  -H 'Accept: application/activity+json' \
  https://it.fedimeteo.com/some/activitypub/object
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A cache that works should be visible. A cache that is invisible can be correct, but it can also be silently wrong. I prefer to know.&lt;/p&gt;
&lt;h2&gt;Compression and operational paths&lt;/h2&gt;
&lt;p&gt;HAProxy also handles gzip compression:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;filter compression
compression algo gzip
compression type text/css text/html text/javascript application/javascript text/plain text/xml application/json application/activity+json
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This keeps another common responsibility at the edge. The country instances can stay focused on snac and the forecast data, while HAProxy deals with client-facing compression for HTML, JSON, and ActivityPub responses.&lt;/p&gt;
&lt;p&gt;There is also a local Prometheus exporter:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-haproxy"&gt;frontend prometheus
  bind 127.0.0.1:8405
  mode http
  http-request use-service prometheus-exporter
  no log
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And I keep internal operational paths, such as statistics and Grafana, handled before the hostname map. These are small details, but ordering matters. Special paths should be explicit and early. The hostname map is for FediMeteo routing, not for every internal tool I happen to expose behind the same proxy.&lt;/p&gt;
&lt;h2&gt;What this changes in practice&lt;/h2&gt;
&lt;p&gt;The nice thing about this configuration is that none of its parts is particularly surprising.&lt;/p&gt;
&lt;p&gt;The map keeps hostname routing manageable. The backend definitions keep each country isolated and limited. The static homepage avoids dynamic work for something that changes once per hour. The shared avatar gives HAProxy one very hot media object to serve directly. The media cache keeps public files away from snac. The JSON microcache absorbs short ActivityPub bursts. Header cleanup prevents useless variation. Connection reuse avoids unnecessary backend connection churn.&lt;/p&gt;
&lt;p&gt;But all of this is only a longer way of saying one thing:&lt;/p&gt;
&lt;p&gt;&lt;em&gt;fewer requests reach snac&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;That is the metric I care about here.&lt;/p&gt;
&lt;p&gt;Not because snac is slow. If anything, FediMeteo exists in its current form because snac is efficient enough to make this kind of project possible on a very small VPS. But precisely because the whole architecture is small and pleasant, I do not want to waste resources where there is no need.&lt;/p&gt;
&lt;p&gt;This is also consistent with the rest of the project. Forecasts are serialized by scripts. Updates happen every six hours. The homepage is regenerated hourly. Countries live in separate jails. Snapshots and backups are handled outside the application. No single component tries to be the entire system.&lt;/p&gt;
&lt;p&gt;HAProxy is just another small piece, but it sits in the right place to remove a lot of repeated work.&lt;/p&gt;
&lt;h2&gt;Caveats&lt;/h2&gt;
&lt;p&gt;This configuration is not a universal HAProxy recipe for ActivityPub services.&lt;/p&gt;
&lt;p&gt;It matches FediMeteo as it is now: almost no media, one shared avatar, static homepage, public forecasts, many small snac instances, and ActivityPub traffic that can benefit from a short public cache when there are no cookies or authorization headers.&lt;/p&gt;
&lt;p&gt;If I decide one day to use media in forecasts, the media cache rules will need to be reviewed. If I use different avatars for each city or country, the cache will still work, but I will lose the very nice property of one shared, always-hot avatar. If ActivityPub responses become actor-dependent, public JSON caching must be reconsidered. If one country grows a very different traffic pattern from the others, it may deserve a different limit or policy.&lt;/p&gt;
&lt;p&gt;This is why I do not like presenting configurations as magic. A good configuration is a written form of the assumptions behind a service. When the assumptions change, the configuration must change too.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;FediMeteo started as a small idea and became larger than I expected, but I still want it to feel small in the right ways. Small does not mean fragile. Small means understandable. It means that each part has a reason to exist, and that unnecessary work is removed before it becomes a problem.&lt;/p&gt;
&lt;p&gt;The HAProxy layer follows this idea. It terminates TLS, routes hostnames through a map, reuses backend connections, serves the shared avatar from cache, microcaches public ActivityPub JSON, avoids authenticated and cookie-based traffic, and gives me a small diagnostic header to see what is happening.&lt;/p&gt;
&lt;p&gt;There is no single brilliant directive here. There is only the usual work of matching infrastructure to reality.&lt;/p&gt;
&lt;p&gt;FediMeteo publishes weather forecasts as text and emoji. The homepage is static HTML updated every hour. The accounts share the same avatar because it is enough, and because it is better for the cache. Each country has its own snac instance in its own FreeBSD jail. HAProxy stands in front of them and tries, quietly, not to bother them unless it has to.&lt;/p&gt;
&lt;p&gt;I like this kind of infrastructure.&lt;/p&gt;
&lt;p&gt;Not because it is invisible, but because when it works well, it leaves very little to say.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Mon, 18 May 2026 09:44:00 +0000</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2026/05/18/fedimeteo-haproxy-and-the-art-of-not-wasting-snac-threads/</guid><category>freebsd</category><category>haproxy</category><category>server</category><category>networking</category><category>hosting</category><category>fediverse</category><category>snac</category><category>jail</category><category>ownyourdata</category><category>snac2</category><category>web</category><category>social</category><category>fedimeteo</category></item><item><title>Monitor your devices with LibreNMS on FreeBSD</title><link>https://it-notes.dragas.net/2026/05/07/monitor-your-services-with-librenms-on-freebsd/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/watchdog.webp" alt="Monitor your devices with LibreNMS on FreeBSD"&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://www.librenms.org"&gt;LibreNMS&lt;/a&gt; has been a faithful companion for years now. It quietly handles the monitoring of my servers, devices, and services without demanding much in return - exactly what you want from a tool whose job is to watch over everything else. It's a solid alternative to heavier solutions like Zabbix, and it gives you alerts, data, and graphs on virtually anything reachable over SNMP.&lt;/p&gt;
&lt;p&gt;I usually install it on a host that is &lt;em&gt;not&lt;/em&gt; reachable from the outside, then let it poll all the devices through a VPN: a single observation point, clean perimeter. The ability to create multiple dashboards - and to filter them by user - has also let me give clients a transparent window onto their own servers. Transparency, in my experience, is always the better long-term bet.&lt;/p&gt;
&lt;p&gt;Together with &lt;a href="https://it-notes.dragas.net/2024/07/22/install-uptime-kuma-freebsd-jail/"&gt;Uptime-Kuma&lt;/a&gt; (and the good old Nagios/Munin pair), LibreNMS lives in a FreeBSD jail on my monitoring servers and just does its job.&lt;/p&gt;
&lt;p&gt;This post walks through a plain installation of LibreNMS on FreeBSD: package-based, no reverse proxy, no HTTPS, no fancy hardening. The goal is to get to a working setup you can build on top of.&lt;/p&gt;
&lt;h2&gt;Assumptions&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;FreeBSD 15.0-RELEASE, in a jail or on a dedicated VM/host&lt;/li&gt;
&lt;li&gt;nginx + php-fpm + MySQL 8.4&lt;/li&gt;
&lt;li&gt;LibreNMS installed from the official package - not via &lt;code&gt;git clone&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One note before we start: in this guide I use plain HTTP just to reach the first-time setup. If your LibreNMS instance won't stay confined to a private network or behind a VPN, configuring HTTPS is mandatory, not optional.&lt;/p&gt;
&lt;h2&gt;Installation&lt;/h2&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;pkg install librenms mysql84-server python3 nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;LibreNMS currently depends on PHP 8.4. If you want to speed PHP up, install OPcache too:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;pkg install php84-opcache
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;MySQL&lt;/h2&gt;
&lt;p&gt;Two settings need to be in place &lt;em&gt;before&lt;/em&gt; MySQL starts for the first time. After the first start they cannot be changed without reinitializing the data directory, so it's worth getting them right now.&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;cd /usr/local/etc/mysql
cp my.cnf.sample my.cnf
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In the &lt;code&gt;[mysqld]&lt;/code&gt; section, add:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;innodb_file_per_table=1
lower_case_table_names=0
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now start MySQL:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;service mysql-server enable
service mysql-server start
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;On a fresh FreeBSD install, the local &lt;code&gt;root&lt;/code&gt; user can connect to MySQL without a password from the command line. Connect and create the database and user. I'm using &lt;code&gt;password&lt;/code&gt; here as a placeholder - don't.&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;mysql
&lt;/code&gt;&lt;/pre&gt;

&lt;pre class="highlight"&gt;&lt;code class="language-sql"&gt;CREATE DATABASE librenms CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'librenms'@'localhost' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON librenms.* TO 'librenms'@'localhost';
exit
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;php-fpm&lt;/h2&gt;
&lt;p&gt;Edit &lt;code&gt;/usr/local/etc/php-fpm.d/www.conf&lt;/code&gt; and adjust the listen directives:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;listen = /var/run/php-fpm-librenms.sock
listen.owner = www
listen.group = www
listen.mode = 0660
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then create &lt;code&gt;php.ini&lt;/code&gt; from the production sample:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;cd /usr/local/etc
cp php.ini-production php.ini
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And set the timezone in &lt;code&gt;php.ini&lt;/code&gt;:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;date.timezone = Europe/Rome
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;nginx&lt;/h2&gt;
&lt;p&gt;Since this jail (or host) is dedicated to LibreNMS, we can rewrite the &lt;code&gt;server&lt;/code&gt; block in &lt;code&gt;/usr/local/etc/nginx/nginx.conf&lt;/code&gt; directly:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;server {
    listen      80;
    #server_name yourServerName
    root        /usr/local/www/librenms/html;
    index       index.php;

    charset utf-8;
    gzip on;
    gzip_types text/css application/javascript text/javascript application/x-javascript image/svg+xml text/plain text/xsd text/xsl text/xml image/x-icon;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location /api/v0 {
        try_files $uri $uri/ /api_v0.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.*)$;
        set $path_info $fastcgi_path_info;
        try_files $fastcgi_script_name =404;
        include fastcgi_params;
        fastcgi_param SERVER_SOFTWARE &amp;quot;&amp;quot;;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $path_info;
        fastcgi_index index.php;
        fastcgi_pass unix:/var/run/php-fpm-librenms.sock;
        fastcgi_buffers 256 4k;
        fastcgi_intercept_errors on;
        fastcgi_read_timeout 14400;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now start nginx and php-fpm:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;service nginx enable
service nginx start

service php_fpm enable
service php_fpm start
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;LibreNMS configuration&lt;/h2&gt;
&lt;p&gt;Copy the default config:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;cp /usr/local/www/librenms/config.php.default /usr/local/www/librenms/config.php
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Because we installed from the package, this file already has the right commands and paths for FreeBSD - no need to hunt down &lt;code&gt;mtr&lt;/code&gt;, &lt;code&gt;fping&lt;/code&gt;, &lt;code&gt;snmpwalk&lt;/code&gt; and friends one by one.&lt;/p&gt;
&lt;p&gt;Create the directory for RRD graphs and set ownership:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;mkdir -p /var/db/librenms/rrd
chown -R www:www /var/db/librenms
chmod 775 /var/db/librenms/rrd
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then the &lt;code&gt;.env&lt;/code&gt; file:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;cd /usr/local/www/librenms
cp .env.example .env
chown www .env
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Edit &lt;code&gt;.env&lt;/code&gt; and set at least:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;DB_DATABASE&lt;/code&gt; - &lt;code&gt;librenms&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DB_USERNAME&lt;/code&gt; - &lt;code&gt;librenms&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DB_PASSWORD&lt;/code&gt; - the one you actually used (not &lt;code&gt;password&lt;/code&gt;, please)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Then add this line, which tells LibreNMS we still need to run the web installer:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;INSTALL=true
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;A note on permissions. The official LibreNMS documentation suggests &lt;code&gt;chown -R www:www&lt;/code&gt; over the entire application tree, but on FreeBSD the package already lays down sane ownership, with &lt;code&gt;storage/&lt;/code&gt; and &lt;code&gt;bootstrap/cache/&lt;/code&gt; writable by &lt;code&gt;www&lt;/code&gt;. There's no reason to widen the rest of the codebase. If &lt;code&gt;validate.php&lt;/code&gt; complains later about something write-related, the first place to check is:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;ls -la /usr/local/www/librenms/storage /usr/local/www/librenms/bootstrap/cache
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now generate the app key as &lt;code&gt;www&lt;/code&gt;, since the file is owned by &lt;code&gt;www&lt;/code&gt;:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;su -m www -c &amp;quot;php artisan key:generate&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And tighten &lt;code&gt;.env&lt;/code&gt;:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;chmod 600 .env
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Refresh the configuration cache:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;su -m www -c &amp;quot;lnms config:clear&amp;quot;
su -m www -c &amp;quot;lnms config:cache&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Web installer&lt;/h2&gt;
&lt;p&gt;Open &lt;code&gt;http://host/install&lt;/code&gt; and follow the steps. The validation process may fail. Refreshing the cache picks up the values written to &lt;code&gt;config.php&lt;/code&gt; during the install:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;su -m www -c &amp;quot;lnms config:clear&amp;quot;
su -m www -c &amp;quot;lnms config:cache&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;When the web installer is done, edit .env again and remove the INSTALL=true line if it's still there. Leaving it in place re-exposes the installer to anyone who can reach the URL.&lt;/p&gt;
&lt;h2&gt;Polling service&lt;/h2&gt;
&lt;p&gt;LibreNMS needs something to actually run the polls. On FreeBSD, the package ships an rc service that runs the LibreNMS dispatcher, so there's no need to manage cron entries by hand the way most Linux guides assume.&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;service librenms enable
service librenms start
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Validate&lt;/h2&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;cd /usr/local/www/librenms
su -m www -c './validate.php'
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;You may see a couple of complaints right after starting the service - usually scheduler-related and self-resolving within a few minutes. Re-run &lt;code&gt;validate.php&lt;/code&gt; once the dispatcher has had time to settle. Anything still red after that is worth investigating.&lt;/p&gt;
&lt;h2&gt;Next steps&lt;/h2&gt;
&lt;p&gt;At this point you can log into the web interface and start adding devices, configuring SNMP, and building dashboards. For that, the &lt;a href="https://docs.librenms.org/"&gt;official LibreNMS documentation&lt;/a&gt; is excellent, and there's no point in me paraphrasing it here.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Thu, 07 May 2026 10:45:00 +0000</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2026/05/07/monitor-your-services-with-librenms-on-freebsd/</guid><category>freebsd</category><category>monitoring</category><category>server</category><category>networking</category><category>hosting</category><category>tutorial</category><category>security</category><category>jail</category><category>ownyourdata</category></item><item><title>EnshittifAIcation</title><link>https://it-notes.dragas.net/2026/03/20/enshittifaication/</link><description>&lt;p&gt;&lt;img src="https://unsplash.com/photos/vi1HXPw6hyw/download?force=true&amp;w=640" alt="Photo by Immo Wegmann on Unsplash"&gt;&lt;/p&gt;&lt;p&gt;Yesterday morning, first thing after waking up, I checked my emails. One of them was from a client - a sharp person, but not a tech expert - forwarding a message from one of their "digital marketplaces". They claimed that during site crawling, their bot upgrades the connection to HTTP/2, and that this somehow causes issues on their end, so they were asking us to disable HTTP/2 to fix the problem.&lt;/p&gt;
&lt;p&gt;I contacted Alex directly - the person (spoiler: not a person) who had sent the email - explaining that if their bot has trouble with HTTP/2 (which, on the contrary, provides significant benefits for the e-commerce experience in question), that's their problem, not ours, and they should fix it. Completely unprompted, I received something unexpected in reply: a guide on how to configure Apache to do what they wanted. The problem? Not only did it completely ignore my stated position, but we don't use Apache - we use nginx. And, I should add, their guide was entirely wrong. I replied pointing all of this out and finally asked to be "escalated to a human, since I was clearly talking to an AI that wasn't understanding any of my responses". The reply was blunt:  &lt;em&gt;"That's not possible for this type of issue. Follow our guide or we will suspend your service and your e-commerce visibility."&lt;/em&gt;  For me, obviously, that's a hard pass. For my client, though, it's a real problem - an intelligent person who understood the situation, but still a problem to solve.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;Over the past few months, I've been witnessing a dramatic increase in botnet attacks targeting some of the servers I manage, especially e-commerce ones. These aren't directed at me personally - they also hit servers I manage on behalf of clients. At first I thought they were AI scrapers, but the traffic comes from everywhere, especially from residential connections scattered around the world. I believe these are deliberate disruption campaigns, a side effect of the turbulent geopolitical climate we're living through.&lt;/p&gt;
&lt;p&gt;On several of these e-commerce servers, we decided to implement geo-blocking, as &lt;a href="https://it-notes.dragas.net/2024/06/16/freebsd-blocking-country-access/"&gt;I've described previously on this blog&lt;/a&gt;. Normally, once you've identified your whitelist countries and the shop isn't a global operation, everything works fine. In other cases, problems arise.&lt;/p&gt;
&lt;p&gt;A few days ago, a partner of one of my clients - a company that provides services and needs access to some prepared XML feeds - started complaining they could no longer connect. I asked them for the IP pool they connect from, or at least the country their connections originate from. Their vague reply was:  &lt;em&gt;"We can't provide that information because we don't have a fixed IP or set of IPs."&lt;/em&gt;  They completely ignored the question about the country. I pushed further, but got nowhere - different "people", giving different answers, all wildly off the mark and ignoring what I was actually asking, insisting instead that I whitelist their user agent. I explained, repeatedly, that the block is at the firewall level - meaning I never even see their user agent: if the connection is dropped, there's no handshake, no HTTP headers, nothing. It didn't matter. They kept repeating the same thing without engaging with what I wrote. Eventually they went directly to my client. I'll paste the exact text:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;We're having some trouble accessing the site and downloading the XML, as they both currently require a VPN connection. To ensure our Lambda functions can run correctly, could you please:&lt;/em&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Remove the location-based restrictions for our access;&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Or, allow the User-Agent "REDACTED" in your firewall/server settings?&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;em&gt;Please let us know which option works best for you.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Let's break this down:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"Require a VPN connection"&lt;/em&gt;  - who said anything about a VPN? Pure hallucination.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Remove the location-based restrictions for our access"&lt;/em&gt;  - they never once answered: which location?&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Allow the user agent"&lt;/em&gt;  - I explained, multiple times, that the block is at the firewall level. The connection is dropped before any handshake occurs. There is no user agent to allow.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;This morning, another client writes:  &lt;em&gt;"The marketing consultancy wants all the server load graphs to get an idea of where we stand."&lt;/em&gt;  This is the second time in just a few days I've received a request like this. I send both the graphs and the full specs of the dedicated server in use - average load under 5%. The response was staggering:  &lt;em&gt;"The internal team, supported by the most advanced AI, believes your current setup is not adequate for the industry, load, and audience you're targeting, and recommends migrating to a cloud VPS with AT LEAST 8 GB of dedicated RAM to ensure sufficient resources, as the current ones are insufficient."&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The current ones? 128 GB of RAM. Two modern CPUs. 48 cores total. If we followed their advice, the site would be down within five minutes - and that's just counting legitimate traffic. My client, unaware of the technical differences, asks me if we can implement what they're suggesting.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;The shift was abrupt - not unlike when an intern arrives convinced they already know everything, often with the best of intentions: bringing fresh air into an environment that needs "modernising". But with an intern, you can talk. That same confidence often turns into curiosity, hunger to learn, real experience. I've watched eager interns grow into excellent professionals - people who eventually surpassed me in skill and success, and that felt genuinely satisfying, knowing I'd contributed, at least in part, to their growth. With AI, this is impossible. It doesn't grow, doesn't listen, doesn't update its mental model based on what you write back - and above all, it doesn't know what it doesn't know.&lt;/p&gt;
&lt;p&gt;That's why I'd like companies to consider that AI systems are stochastic machines, not experts. They can solve some problems, but there's a limit. There will always be a limit, at least with current technology, and we can't afford to ignore it. The damage risks far outweighing the "savings" generated.&lt;/p&gt;
&lt;hr /&gt;
&lt;p&gt;The enormous problem with my work these days is the extreme confidence that certain companies project, replacing humans - even senior ones - with AI, with no right of appeal. The result is monstrous confusion, enormous wasted time for everyone, and a widespread erosion of reliability, all papered over by the AI's unshakeable assertiveness - and by those who believe these systems are the  &lt;em&gt;Answer to the Ultimate Question of Life, the Universe, and Everything&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;Rewarding confidence over actual competence is a bug humanity has always had. It has produced disasters throughout history, it is producing disasters now, and not only in the tech world.&lt;/p&gt;
&lt;p&gt;So I find myself wondering: if they're so convinced that AI is better than senior professionals, why don't they replace the bosses with AI? I'm fairly confident the decisions would be considerably better - and humans would end up exactly where they should be.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Fri, 20 Mar 2026 11:00:00 +0000</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2026/03/20/enshittifaication/</guid><category>sysadmin</category><category>server</category><category>ai</category><category>hosting</category></item><item><title>Static Web Hosting on the Intel N150: FreeBSD, SmartOS, NetBSD, OpenBSD and Linux Compared  </title><link>https://it-notes.dragas.net/2025/11/19/static-web-hosting-intel-n150-freebsd-smartos-netbsd-openbsd-linux/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/server_rack.webp" alt="A server rack with some servers and cables"&gt;&lt;/p&gt;&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update&lt;/strong&gt;: This post has been updated to include &lt;strong&gt;Docker&lt;/strong&gt; benchmarks and a comparison of container overhead versus FreeBSD Jails and illumos Zones.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;: Some operating systems (FreeBSD and Linux) support kernel TLS (kTLS) and the related SSL_sendfile path in nginx, which can improve HTTPS performance for static files. Since this feature is not available on all the systems included in the comparison (for example NetBSD, OpenBSD and illumos), the benchmarks were run with a common baseline configuration that does not rely on kTLS. The goal is to compare the systems under similar conditions rather than to measure OS specific optimizations.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I often get very specific infrastructure requests from clients. Most of the time it is some form of hosting. My job is usually to suggest and implement the setup that fits their goals, skills and long term plans.  &lt;/p&gt;
&lt;p&gt;If there are competent technicians on the other side, and they are willing to learn or already comfortable with Unix style systems, my first choices are usually one of the BSDs or an illumos distribution. If they need a control panel, or they already have a lot of experience with a particular stack that will clearly help them, I will happily use Linux and it usually delivers solid, reliable results.  &lt;/p&gt;
&lt;p&gt;Every now and then someone asks the question I like the least:  &lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;“But how does it &lt;em&gt;perform&lt;/em&gt; compared to X or Y?”  &lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I have never been a big fan of benchmarks. At best they capture a very specific workload on a very specific setup. They are almost never a perfect reflection of what will happen in the real world.  &lt;/p&gt;
&lt;p&gt;For example, I discovered that idle bhyve VMs seem to use fewer resources when the host is illumos than when the host is FreeBSD. It looks strange at first sight, but the illumos people are clearly working very hard on this, and the result is a very capable and efficient platform.  &lt;/p&gt;
&lt;p&gt;Despite my skepticism, from time to time I enjoy running some comparative tests. I already did it with &lt;a href="https://it-notes.dragas.net/2024/06/10/proxmox-vs-freebsd-which-virtualization-host-performs-better/"&gt;Proxmox KVM versus FreeBSD bhyve&lt;/a&gt;, and I also &lt;a href="https://it-notes.dragas.net/2025/09/19/freebsd-vs-smartos-whos-faster-for-jails-zones-bhyve/"&gt;compared Jails, Zones, bhyve and KVM&lt;/a&gt; on the same Intel N150 box. That led to the FreeBSD vs SmartOS article where I focused on CPU and memory performance on this small mini PC.  &lt;/p&gt;
&lt;p&gt;This time I wanted to do something simpler, but also closer to what I see every day: &lt;strong&gt;static web hosting.&lt;/strong&gt;  &lt;/p&gt;
&lt;p&gt;Instead of synthetic CPU or I/O tests, I wanted to measure how different operating systems behave when they serve a small static site with nginx, both over HTTP and HTTPS.  &lt;/p&gt;
&lt;p&gt;This is &lt;strong&gt;not&lt;/strong&gt; meant to be a super rigorous benchmark. I used the default nginx packages, almost default configuration, and did not tune any OS specific kernel settings. In my experience, careful tuning of kernel and network parameters can easily move numbers by several tens of percentage points. The problem is that very few people actually spend time chasing such optimizations. Much more often, once a limit is reached, someone yells “we need mooooar powaaaar” while the real fix would be to tune the existing stack a bit.&lt;/p&gt;
&lt;p&gt;So the question I want to answer here is more modest and more practical:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;With default nginx and a small static site, how much does the choice of host OS really matter on this Intel N150 mini PC?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;Spoiler&lt;/em&gt;: less than people think, at least for plain HTTP. Things get more interesting once TLS enters the picture.&lt;/p&gt;
&lt;hr /&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;&lt;br /&gt;
These benchmarks are a snapshot of my specific hardware, network and configuration. They are useful to compare &lt;em&gt;relative&lt;/em&gt; behavior on this setup. They are not a universal ranking of operating systems. Different CPUs, NICs, crypto extensions, kernel versions or nginx builds can completely change the picture.  &lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr /&gt;
&lt;h2&gt;Test setup&lt;/h2&gt;
&lt;p&gt;The hardware is the same Intel N150 mini PC I used in my previous tests: a small, low power box that still has enough cores to be interesting for lab and small production workloads.  &lt;/p&gt;
&lt;p&gt;On it, I installed several operating systems and environments, always on the bare metal, not nested inside each other. On each OS I installed nginx from the official packages.  &lt;/p&gt;
&lt;h3&gt;Software under test&lt;/h3&gt;
&lt;p&gt;On the host:  &lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SmartOS&lt;/strong&gt;, with:&lt;br /&gt;
- a Debian 12 LX zone&lt;br /&gt;
- an Alpine Linux 3.22 LX zone&lt;br /&gt;
- a native SmartOS zone  &lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FreeBSD&lt;/strong&gt; 14.3-RELEASE:&lt;br /&gt;
- nginx running inside a native jail  &lt;/p&gt;
&lt;p&gt;&lt;strong&gt;OpenBSD&lt;/strong&gt; 7.8:&lt;br /&gt;
- nginx on the host  &lt;/p&gt;
&lt;p&gt;&lt;strong&gt;NetBSD&lt;/strong&gt; 10.1:&lt;br /&gt;
- nginx on the host  &lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Debian&lt;/strong&gt; 13.2:&lt;br /&gt;
- nginx on the host &lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Alpine Linux&lt;/strong&gt; 3.22:&lt;br /&gt;
- nginx on the host&lt;br /&gt;
- Docker: Debian 13 container running on the Alpine host (ports mapped)&lt;/p&gt;
&lt;p&gt;I also tried to include &lt;strong&gt;DragonFlyBSD&lt;/strong&gt;, but the NIC in this box is not supported. Using a different NIC just for one OS would have made the comparison meaningless, so I excluded it.  &lt;/p&gt;
&lt;h3&gt;nginx configuration&lt;/h3&gt;
&lt;p&gt;In all environments:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;nginx was installed from the system packages  &lt;/li&gt;
&lt;li&gt;&lt;code&gt;worker_processes&lt;/code&gt; was set to &lt;code&gt;auto&lt;/code&gt;  &lt;/li&gt;
&lt;li&gt;the web root contained the same static content  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The important part is that I used &lt;strong&gt;exactly the same &lt;code&gt;nginx.conf&lt;/code&gt; file for all operating systems and all combinations in this article&lt;/strong&gt;. I copied the same configuration file verbatim to every host, jail and zone. The only changes were the IP address and file paths where needed, for example for the TLS certificate and key.  &lt;/p&gt;
&lt;p&gt;The static content was a default build of the example site generated by &lt;a href="https://bssg.dragas.net/"&gt;&lt;strong&gt;BSSG&lt;/strong&gt;, my Bash static site generator&lt;/a&gt;. The web root was the same logical structure on every OS and container type.  &lt;/p&gt;
&lt;p&gt;There is no OS specific tuning in the configuration and no kernel level tweaks. This is very close to a “package install plus minimal config” situation.  &lt;/p&gt;
&lt;h3&gt;TLS configuration&lt;/h3&gt;
&lt;p&gt;For HTTPS I used a very simple configuration, identical on every host.  &lt;/p&gt;
&lt;p&gt;Self signed certificate created with:  &lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;openssl req -x509 -newkey rsa:4096 -nodes -keyout server.key -out server.crt -days 365 -subj &amp;quot;/CN=localhost&amp;quot;  
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Example nginx &lt;code&gt;server&lt;/code&gt; block for HTTPS (simplified):  &lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;server {  
listen 443 ssl http2;  
listen [::]:443 ssl http2;  

server_name _;  

ssl_certificate /etc/nginx/ssl/server.crt;  
ssl_certificate_key /etc/nginx/ssl/server.key;  

root /var/www/html;  
index index.html index.htm;  

location / {  
try_files $uri $uri/ =404;  
}  
}  
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The HTTP virtual host is also the same everywhere, with the root pointing to the BSSG example site.  &lt;/p&gt;
&lt;h3&gt;Load generator&lt;/h3&gt;
&lt;p&gt;The tests were run from my workstation on the same LAN:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;client host: a mini PC machine connected at 2.5 Gbit/s  &lt;/li&gt;
&lt;li&gt;switch: 2.5 Gbit/s  &lt;/li&gt;
&lt;li&gt;test tool: &lt;code&gt;wrk&lt;/code&gt;  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For each target host I ran:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;wrk -t4 -c50 -d10s http://IP&lt;/code&gt;  &lt;/li&gt;
&lt;li&gt;&lt;code&gt;wrk -t4 -c10 -d10s http://IP&lt;/code&gt;  &lt;/li&gt;
&lt;li&gt;&lt;code&gt;wrk -t4 -c50 -d10s https://IP&lt;/code&gt;  &lt;/li&gt;
&lt;li&gt;&lt;code&gt;wrk -t4 -c10 -d10s https://IP&lt;/code&gt;  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each scenario was executed multiple times to reduce noise; the numbers below are medians (or very close to them) from the runs.&lt;/p&gt;
&lt;h2&gt;The contenders&lt;/h2&gt;
&lt;p&gt;To keep things readable, I will refer to each setup as follows:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SmartOS Debian LX&lt;/strong&gt; → SmartOS host, Debian 12 LX zone  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SmartOS Alpine LX&lt;/strong&gt; → SmartOS host, Alpine 3.22 LX zone  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SmartOS Native&lt;/strong&gt; → SmartOS host, native zone  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FreeBSD Jail&lt;/strong&gt; → FreeBSD 14.3-RELEASE, nginx in a jail  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OpenBSD Host&lt;/strong&gt; → OpenBSD 7.8, nginx on the host  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NetBSD Host&lt;/strong&gt; → NetBSD 10.1, nginx on the host  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Debian Host&lt;/strong&gt; → Debian 13.2, nginx on the host  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Alpine Host&lt;/strong&gt; → Alpine 3.22, nginx on the host  &lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Docker Container&lt;/strong&gt; → Alpine host, Debian 13 Docker container&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Everything uses the same nginx configuration file and the same static site.  &lt;/p&gt;
&lt;h2&gt;Static HTTP results&lt;/h2&gt;
&lt;p&gt;Let us start with plain HTTP, since this removes TLS from the picture and focuses on the kernel, network stack and nginx itself.  &lt;/p&gt;
&lt;h3&gt;HTTP, 4 threads, 50 concurrent connections&lt;/h3&gt;
&lt;p&gt;Approximate median &lt;code&gt;wrk&lt;/code&gt; results:  &lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;HTTP 50 connections&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SmartOS Debian LX&lt;/td&gt;
&lt;td&gt;~46.2 k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmartOS Alpine LX&lt;/td&gt;
&lt;td&gt;~49.2 k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmartOS Native&lt;/td&gt;
&lt;td&gt;~63.7 k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FreeBSD Jail&lt;/td&gt;
&lt;td&gt;~63.9 k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenBSD Host&lt;/td&gt;
&lt;td&gt;~64.1 k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NetBSD Host&lt;/td&gt;
&lt;td&gt;~64.0 k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debian Host&lt;/td&gt;
&lt;td&gt;~63.8 k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alpine Host&lt;/td&gt;
&lt;td&gt;~63.9 k&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker Container&lt;/td&gt;
&lt;td&gt;~63.7 k&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two things stand out:  &lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;All the native or jail/container setups on the hosts that are not LX zones cluster around 63 to 64k requests per second.  &lt;/li&gt;
&lt;li&gt;The two SmartOS LX zones sit slightly lower, in the 46 to 49k range, which is still very respectable for this hardware.  &lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;In other words, as long as you are on the host or in something very close to it (FreeBSD jail, SmartOS native zone, NetBSD, OpenBSD, Linux on bare metal), static HTTP on nginx will happily max out around 64k requests per second with this small Intel N150 CPU.  &lt;/p&gt;
&lt;p&gt;The Debian and Alpine LX zones on SmartOS are a bit slower, but not dramatically so. They still deliver close to 50k requests per second and, in a real world scenario, you would probably saturate the network or the client long before hitting those numbers.  &lt;/p&gt;
&lt;h3&gt;HTTP, 4 threads, 10 concurrent connections&lt;/h3&gt;
&lt;p&gt;With fewer concurrent connections, absolute throughput drops, but the relative picture is similar:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;SmartOS Native around 44k  &lt;/li&gt;
&lt;li&gt;NetBSD and Alpine Host around 34 to 35k  &lt;/li&gt;
&lt;li&gt;FreeBSD, Debian, OpenBSD around 31 to 33k  &lt;/li&gt;
&lt;li&gt;The Docker Container sits slightly lower at ~30.2k req/s, showing a small overhead from the networking layer  &lt;/li&gt;
&lt;li&gt;The SmartOS LX zones sit slightly below, around 35 to 37k req/s  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The important conclusion is simple:  &lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;For plain HTTP static hosting, once nginx is installed and correctly configured, the choice between these operating systems makes very little difference on this hardware. Zones and jails add negligible overhead, LX zones add a small one.  &lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;If you are only serving static content over HTTP, your choice of OS should be driven by other factors: ecosystem, tooling, update strategy, your own expertise and preference.  &lt;/p&gt;
&lt;h2&gt;Static HTTPS results&lt;/h2&gt;
&lt;p&gt;TLS is where things start to diverge more clearly and where CPU utilization becomes interesting.  &lt;/p&gt;
&lt;h3&gt;HTTPS, 4 threads, 50 concurrent connections&lt;/h3&gt;
&lt;p&gt;Approximate medians:  &lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Environment&lt;/th&gt;
&lt;th&gt;HTTPS 50 connections&lt;/th&gt;
&lt;th&gt;CPU notes at 50 HTTPS connections&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SmartOS Debian LX&lt;/td&gt;
&lt;td&gt;~51.4 k&lt;/td&gt;
&lt;td&gt;CPU saturated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmartOS Alpine LX&lt;/td&gt;
&lt;td&gt;~40.4 k&lt;/td&gt;
&lt;td&gt;CPU saturated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SmartOS Native&lt;/td&gt;
&lt;td&gt;~52.8 k&lt;/td&gt;
&lt;td&gt;CPU saturated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FreeBSD Jail&lt;/td&gt;
&lt;td&gt;~62.9 k&lt;/td&gt;
&lt;td&gt;around 60% CPU idle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenBSD Host&lt;/td&gt;
&lt;td&gt;~39.7 k&lt;/td&gt;
&lt;td&gt;CPU saturated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NetBSD Host&lt;/td&gt;
&lt;td&gt;~40.4 k&lt;/td&gt;
&lt;td&gt;CPU saturated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debian Host&lt;/td&gt;
&lt;td&gt;~62.8 k&lt;/td&gt;
&lt;td&gt;about 20% CPU idle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alpine Host&lt;/td&gt;
&lt;td&gt;~62.4 k&lt;/td&gt;
&lt;td&gt;small idle headroom, around 7% idle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker Container&lt;/td&gt;
&lt;td&gt;~62.7 k&lt;/td&gt;
&lt;td&gt;CPU saturated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;These numbers tell a more nuanced story.  &lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;FreeBSD, Debian and Alpine on bare metal form a “fast TLS” group.&lt;/strong&gt;&lt;br /&gt;
All three sit around 62 to 63k requests per second with 50 concurrent HTTPS connections.  &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;FreeBSD does this while using significantly less CPU.&lt;/strong&gt;&lt;br /&gt;
During the HTTPS tests with 50 connections, the FreeBSD host still had around 60% CPU idle. It is the platform that handled TLS load most comfortably in terms of CPU headroom.  &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Debian and Alpine are close in throughput, but push the CPU harder.&lt;/strong&gt;&lt;br /&gt;
Debian still had some idle time left, Alpine even less. In practice, all three are excellent here, but FreeBSD gives you more room before you hit the wall.  &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;SmartOS, NetBSD and OpenBSD form a “good but heavier” TLS group.&lt;/strong&gt;&lt;br /&gt;
Their HTTPS throughput is in the 40 to 52k req/s range and they reach full CPU usage at 50 concurrent connections. OpenBSD and NetBSD stabilize around 39 to 40k req/s. SmartOS native and the Debian LX zone manage slightly better (around 51 to 53k) but still with the CPU pegged.  &lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h3&gt;HTTPS, 4 threads, 10 concurrent connections&lt;/h3&gt;
&lt;p&gt;With lower concurrency:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FreeBSD, Debian and Alpine still sit in roughly the 29 to 31k req/s range  &lt;/li&gt;
&lt;li&gt;SmartOS Native and LX zones are in the mid to high 30k range  &lt;/li&gt;
&lt;li&gt;The Docker Container drops slightly to ~27.8k req/s  &lt;/li&gt;
&lt;li&gt;NetBSD and OpenBSD sit around 26 to 27k req/s  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The relative pattern is the same: for this TLS workload, FreeBSD and modern Linux distributions on bare metal appear to make better use of the cryptographic capabilities of the CPU, delivering higher throughput or more headroom or both.  &lt;/p&gt;
&lt;h2&gt;What TLS seems to highlight&lt;/h2&gt;
&lt;p&gt;The HTTPS tests point to something that is not about nginx itself, but about the TLS stack and how well it can exploit the hardware.  &lt;/p&gt;
&lt;p&gt;On this Intel N150, my feeling is:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;FreeBSD, with the userland and crypto stack I am running, is very efficient at TLS here. It delivers the highest throughput while keeping plenty of CPU in reserve.  &lt;/li&gt;
&lt;li&gt;Debian and Alpine, with their recent kernels and libraries, are also strong performers, close to FreeBSD in throughput, but with less idle CPU.  &lt;/li&gt;
&lt;li&gt;NetBSD, OpenBSD and SmartOS (native and LX) are still perfectly capable of serving a lot of HTTPS traffic, but they have to work harder to keep up and they hit 100% CPU much earlier.  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This matches what I see in day to day operations: TLS performance is often less about “nginx vs something else” and more about the combination of:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;the TLS library version and configuration  &lt;/li&gt;
&lt;li&gt;how well the OS uses the CPU crypto instructions  &lt;/li&gt;
&lt;li&gt;kernel level details in the network and crypto paths  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;I suspect the differences here are mostly due to how each system combines its TLS stack (OpenSSL, LibreSSL and friends), its kernel and its hardware acceleration support. It would take a deeper dive into profiling and configuration knobs to attribute the gaps precisely.  &lt;/p&gt;
&lt;p&gt;In any case, on this specific mini PC, if I had to pick a platform to handle a large amount of HTTPS static traffic, FreeBSD, Debian and Alpine would be my first candidates, in that order.  &lt;/p&gt;
&lt;h2&gt;Zones, jails, containers and Docker: overhead in practice&lt;/h2&gt;
&lt;p&gt;Another interesting part of the story is the overhead introduced by different isolation technologies.  &lt;/p&gt;
&lt;p&gt;From these tests and the &lt;a href="https://it-notes.dragas.net/2025/09/19/freebsd-vs-smartos-whos-faster-for-jails-zones-bhyve/"&gt;previous virtualization article on the same N150 machine&lt;/a&gt;, the picture is consistent:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;FreeBSD jails behave almost like bare metal and are significantly more efficient than Docker.&lt;/strong&gt;&lt;br /&gt;
For both HTTP and HTTPS, running nginx in a jail on FreeBSD 14.3-RELEASE produces numbers practically identical to native hosts.&lt;br /&gt;
The contrast with Docker is striking: while the Docker container required 100% CPU to reach peak for the HTTP and HTTPS throughput, &lt;strong&gt;the FreeBSD jail delivered the same speed with ~60% of the CPU sitting idle&lt;/strong&gt;. In terms of performance cost per request, Jails are drastically cheaper.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;SmartOS native zones are also very close to the metal.&lt;/strong&gt;&lt;br /&gt;
Static HTTP performance reaches the same 64k req/s region and HTTPS is only slightly behind the "fast TLS" group, although with higher CPU usage.  &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;SmartOS LX zones introduce a noticeable but modest overhead.&lt;/strong&gt;&lt;br /&gt;
Both Debian and Alpine LX zones on SmartOS perform slightly worse than the native zone or FreeBSD jails. For static HTTP they are still very fast. For HTTPS the Debian LX zone remains competitive but costs more CPU, while the Alpine LX zone is slower.  &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Docker on Linux performs efficiently but eats the margins.&lt;/strong&gt;
I ran an additional test using a Debian 13 Docker container running on the Alpine Linux host.
At peak load (50 connections), the throughput was impressive and virtually identical to bare metal: ~63.7k req/s for HTTP and ~62.7k req/s for HTTPS.
However, there is a clear cost. First, while the bare metal host maintained a small CPU buffer (~7% idle) during the HTTPS test, Docker &lt;strong&gt;saturated the CPU to 100%&lt;/strong&gt;.
Second, at lower concurrency (10 connections), the overhead became visible. The Docker container scored ~30.2k req/s for HTTP and ~27.8k req/s for HTTPS, slightly trailing the ~31-34k and ~29-31k range of the bare metal counterparts. The abstraction layers (NAT, bridging, namespaces) are extremely efficient, but they are not completely free.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This leads to a clear conclusion on efficiency: &lt;strong&gt;FreeBSD Jails provide the highest throughput with the lowest CPU cost.&lt;/strong&gt; LX zones and Docker containers can match the speed (or come close), but they burn significantly more CPU cycles to do so.&lt;/p&gt;
&lt;h2&gt;What this means for real workloads&lt;/h2&gt;
&lt;p&gt;It is easy to get lost in tables and percentages, so let us go back to the initial question.  &lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;A client wants static hosting.&lt;br /&gt;
Does the choice between FreeBSD, SmartOS, NetBSD or Linux matter in terms of performance?  &lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;For &lt;strong&gt;plain HTTP&lt;/strong&gt; on this hardware, with nginx and the same configuration:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Not really.&lt;br /&gt;
All the native hosts and FreeBSD jails deliver roughly the same maximum throughput, in the 63 to 64k req/s range. SmartOS LX zones are slightly slower but still strong.  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For &lt;strong&gt;HTTPS&lt;/strong&gt;:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Yes, it starts to matter a bit more.  &lt;/li&gt;
&lt;li&gt;FreeBSD stands out for how relaxed the CPU is under high TLS load.  &lt;/li&gt;
&lt;li&gt;Debian and Alpine are very close in throughput, with more CPU used but still with some headroom.  &lt;/li&gt;
&lt;li&gt;SmartOS, NetBSD and OpenBSD can still push a lot of HTTPS traffic, but they reach 100% CPU earlier and stabilize at lower request rates.  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Does this mean you should always choose FreeBSD or Debian or Alpine for static HTTPS hosting?  &lt;/p&gt;
&lt;p&gt;Not necessarily.  &lt;/p&gt;
&lt;p&gt;In real deployments, the bottleneck is rarely the TLS performance of a single node serving a small static site. Network throughput, storage, logging, reverse proxies, CDNs and application layers all play a role.  &lt;/p&gt;
&lt;p&gt;However, knowing that FreeBSD and current Linux distributions can squeeze more out of a small CPU under TLS is useful when you are:  &lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;sizing hardware for small VPS nodes that must serve many HTTPS requests  &lt;/li&gt;
&lt;li&gt;planning to consolidate multiple services on a low power box  &lt;/li&gt;
&lt;li&gt;deciding whether you can afford to keep some CPU aside for other tasks (cache, background jobs, monitoring, and so on)  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;As always, the right answer depends on the complete picture: your skills, your tooling, your backups, your monitoring, the rest of your stack, and your tolerance for troubleshooting when things go sideways.  &lt;/p&gt;
&lt;h2&gt;Final thoughts&lt;/h2&gt;
&lt;p&gt;From these small tests, my main takeaways are:  &lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Static HTTP is basically solved on all these platforms.&lt;/strong&gt;&lt;br /&gt;
On a modest Intel N150, every system tested can push around 64k static HTTP requests per second with nginx set to almost default settings. For many use cases, that is already more than enough.  &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;TLS performance is where the OS and crypto stack start to matter.&lt;/strong&gt;&lt;br /&gt;
FreeBSD, Debian and Alpine squeeze more HTTPS requests out of the N150, and FreeBSD in particular does it with a surprising amount of idle CPU left. NetBSD, OpenBSD and SmartOS need more CPU to reach similar speeds and stabilize at lower throughput once the CPU is saturated.  &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Jails and native zones are essentially free, LX zones cost a bit more.&lt;/strong&gt;&lt;br /&gt;
FreeBSD jails and SmartOS native zones show very little overhead for this workload. SmartOS LX zones are still perfectly usable, but if you are chasing every last request per second you will see the cost of the translation layer.  &lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Benchmarks are only part of the story.&lt;/strong&gt;&lt;br /&gt;
If your team knows OpenBSD inside out and has tooling, scripts and workflows built around it, you might happily accept using more CPU on TLS in exchange for security features, simplicity and familiarity. The same goes for NetBSD or SmartOS in environments where their specific strengths shine.  &lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;I will not choose an operating system for a client just because a benchmark looks nicer. These numbers are one of the many inputs I consider. What matters most is always the combination of reliability, security, maintainability and the human beings who will have to operate the&lt;br /&gt;
system at three in the morning when something goes wrong.  &lt;/p&gt;
&lt;p&gt;Still, it is nice to know that if you put a tiny Intel N150 in front of a static site and you pick FreeBSD or a modern Linux distribution for HTTPS, you are giving that little CPU a fair chance to shine.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Wed, 19 Nov 2025 09:16:00 +0100</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2025/11/19/static-web-hosting-intel-n150-freebsd-smartos-netbsd-openbsd-linux/</guid><category>freebsd</category><category>smartos</category><category>illumos</category><category>linux</category><category>netbsd</category><category>openbsd</category><category>jail</category><category>zones</category><category>docker</category><category>hosting</category><category>server</category><category>sysadmin</category><category>ownyourdata</category></item><item><title>Self-hosting your Mastodon media with SeaweedFS</title><link>https://it-notes.dragas.net/2025/11/06/self-hosting-your-mastodon-media-with-seaweedfs/</link><description>&lt;p&gt;&lt;img src="https://images.unsplash.com/photo-1611926653458-09294b3142bf?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwxMTc3M3wwfDF8c2VhcmNofDR8fHNvY2lhbCUyMG5ldHdvcmt8ZW58MHx8fHwxNjY5MTI0MzQ5&amp;ixlib=rb-4.0.3&amp;q=80&amp;w=2000" alt="Self-hosting your Mastodon media with SeaweedFS"&gt;&lt;/p&gt;&lt;p&gt;&lt;a href="https://joinmastodon.org/"&gt;Mastodon&lt;/a&gt; 4.5.0 is here, and with it come some interesting changes that, in my opinion, might encourage more people to consider it for self-hosting their Fediverse community.&lt;/p&gt;
&lt;p&gt;While it may not be as lightweight and simple as other solutions (like &lt;a href="https://codeberg.org/grunfink/snac2"&gt;snac&lt;/a&gt; or &lt;a href="https://gotosocial.org/"&gt;GoToSocial&lt;/a&gt; or many others), I believe it remains one of the best platforms for managing a medium-sized Fediverse community, thanks in part to the direct feedback that many admins have provided to the developers.&lt;/p&gt;
&lt;p&gt;I have previously written about how to &lt;a href="https://it-notes.dragas.net/2022/11/23/installing-mastodon-on-a-freebsd-jail/"&gt;install Mastodon in a FreeBSD jail&lt;/a&gt; and how to &lt;a href="https://it-notes.dragas.net/2024/10/09/2024-modifying-limits-in-mastodon-4-3/"&gt;modify its character and poll limits&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;One of the most critical initial decisions (which can be changed later, but with extra work) is where to store your media files. Mastodon downloads and re-processes all media it encounters from other instances for three main reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Local Caching:&lt;/strong&gt; Your users connect to your media server, reducing the load on the original instance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Security:&lt;/strong&gt; Re-processing media helps to remove any potential "impurities" before they reach the user's device.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Privacy:&lt;/strong&gt; It prevents disclosing your users' IP addresses to other instances. A user will only connect to their own instance to fetch all data, including remote content.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At least initially, media files will be the largest part of your instance's storage footprint. It is therefore essential to plan where to store them and to add a regular cleanup script; otherwise, their growth will be exponential.&lt;/p&gt;
&lt;p&gt;Mastodon supports uploading media to external S3-compatible solutions, and many admins use the usual commercial providers, paying for data uploads and transfers.&lt;/p&gt;
&lt;p&gt;I am a firm believer in "Own Your Data", so I have always used my own self-hosted S3 servers. I initially started with Minio, but over time, I realized that, by design, it doesn't perform well with a multitude of small files (performance degrades). After running some tests, I decided to switch to &lt;a href="https://github.com/seaweedfs/seaweedfs"&gt;SeaweedFS&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;SeaweedFS "is a fast distributed storage system for blobs, objects, files, and data lake, for billions of files! Blob store has O(1) disk seek..." - this, combined with the fact that it is a mature and proven piece of software, was enough for me to give it a try. The result? Excellent. The I/O and CPU load on my media server dropped drastically, making SeaweedFS an incredibly suitable solution. Furthermore, some of its features (like the ability to run a &lt;a href="https://github.com/seaweedfs/seaweedfs/wiki/Filer-Active-Active-cross-cluster-continuous-synchronization"&gt;filer.sync&lt;/a&gt;) allow for efficient and fast replication to other storage, another host, or... anything else.&lt;/p&gt;
&lt;p&gt;SeaweedFS works perfectly with Mastodon, and I will explain the steps to get it into production.&lt;/p&gt;
&lt;p&gt;I will install SeaweedFS in a dedicated jail and use a dedicated subdomain. This ensures that the media server can be moved to another host at any time without reconfiguring everything or changing domains. SeaweedFS has its own FreeBSD package, installable via &lt;code&gt;pkg&lt;/code&gt;, or can be downloaded directly from the project's website.&lt;/p&gt;
&lt;p&gt;In either case, I will describe a "test" setup - which can also be used in production without issues. However, I highly recommend diving deeper into the tool, as it is incredibly powerful and flexible and can solve many more problems than one might imagine.&lt;/p&gt;
&lt;h3&gt;Setting up the SeaweedFS Jail&lt;/h3&gt;
&lt;p&gt;First, let's create a dedicated jail with BastilleBSD:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;bastille create media 14.3-RELEASE 10.0.0.66 bastille0
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now, let's enter the jail and install SeaweedFS (and tmux, which can be useful):&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;bastille console media
pkg install -y tmux seaweedfs
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I suggest launching SeaweedFS in a tmux session so you can monitor its output. Later, you should configure an automatic startup method, such as using the included rc.d file or any other method you prefer.&lt;/p&gt;
&lt;p&gt;Create a directory for the data and start SeaweedFS as the "seaweedfs" user:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;mkdir -p /seaweedfs/data
chown -R seaweedfs /seaweedfs
su -m seaweedfs
cd /seaweedfs/
/usr/local/bin/weed server -dir /seaweedfs/data -s3
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;At this point, SeaweedFS will start and create everything it needs to function, including the S3 server.&lt;/p&gt;
&lt;h3&gt;Configuring Buckets and Users&lt;/h3&gt;
&lt;p&gt;Now, let's open the weed shell to create the necessary bucket and users:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;weed shell
s3.bucket.create -name mastomedia
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Still in the weed shell, create a user for Mastodon and grant read permissions for unauthenticated users (which is necessary to serve media to the world):&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;s3.configure -access_key=mastomedia -secret_key=CHANGEME -buckets=mastomedia -user=mastodon -actions=Read,Write,List,Tagging,Admin -apply
s3.configure -buckets=mastomedia -user=anonymous -actions=Read -apply
s3.configure -buckets=mastomedia -actions=Read -apply
&lt;/code&gt;&lt;/pre&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security Tip:&lt;/strong&gt; For the &lt;code&gt;-secret_key&lt;/code&gt;, avoid using a simple password. You can generate a strong, random key directly from your shell with a command like &lt;code&gt;openssl rand -base64 32&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Done. SeaweedFS is now ready to receive (and serve) media. The next step is to set up a reverse proxy to serve everything over HTTPS. My preferred approach is to configure the system as if it were external, even if the services are in adjacent jails. This might use slightly more resources, but the time and trouble it saves in the future are well worth it.&lt;/p&gt;
&lt;h3&gt;Nginx Reverse Proxy Configuration&lt;/h3&gt;
&lt;p&gt;The reverse proxy can be configured something like this:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;[...]

server {
   server_name  media.mastodon.example.com;

   ignore_invalid_headers off;
   client_max_body_size 0; # Allow large file uploads without Nginx limits

   location / {
      proxy_set_header Host $http_host;
      proxy_set_header X-Real-IP $remote_addr;
      proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header X-Forwarded-Proto $scheme;

      proxy_connect_timeout 300;
      proxy_http_version 1.1;
      proxy_set_header Connection &amp;quot;&amp;quot;;
      chunked_transfer_encoding off;

      expires 1y;
      add_header Cache-Control public;

      add_header X-Cache-Status $upstream_cache_status;
      add_header X-Content-Type-Options nosniff;

      proxy_pass http://10.0.0.66:8333;
   }

# ... other server configurations like SSL ...

}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Mastodon Configuration&lt;/h3&gt;
&lt;p&gt;Now let's configure Mastodon. If you are running the setup wizard for the first time, here is a summary of the options:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;[...]
Do you want to store uploaded files on the cloud? yes
Provider Minio
Minio endpoint URL: https://media.mastodon.example.com
Minio bucket name: mastomedia
Minio access key: mastomedia
Minio secret key: CHANGEME
Do you want to access the uploaded files from your own domain? Yes
Domain for uploaded files: media.mastodon.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If Mastodon is already active, or once the setup is complete, the options in your .env.prod file should be modified to be consistent with what SeaweedFS expects:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;S3_ENABLED=true
S3_PROTOCOL=https
S3_REGION=us-east-1
S3_ENDPOINT=https://media.mastodon.example.com
S3_HOSTNAME=media.mastodon.example.com
S3_BUCKET=mastomedia
AWS_ACCESS_KEY_ID=mastomedia
AWS_SECRET_ACCESS_KEY=CHANGEME
S3_FORCE_SINGLE_REQUEST=true
# remove the S3_ALIAS_HOST if it is set
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;strong&gt;IMPORTANT NOTE:&lt;/strong&gt; If both services are in jails on the same host (i.e., SeaweedFS is on the same host as Mastodon), you should ensure that the Mastodon jail can reach the SeaweedFS jail through the reverse proxy and not via the external IP. To do this, add the following line to the /etc/hosts file of the &lt;strong&gt;Mastodon jail&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;10.0.0.1        media.mastodon.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In this example, the reverse proxy is at 10.0.0.1. If you are not using a separate reverse proxy but are exposing Nginx directly from the jail (as described in my Mastodon installation article), use the IP of the Mastodon jail itself instead (e.g., 10.0.0.42).&lt;/p&gt;
&lt;p&gt;With this setup, Mastodon will be able to upload media to the SeaweedFS server and generate the correct links for other instances, public visitors, and users of your own instance.&lt;/p&gt;
&lt;p&gt;Have fun with SeaweedFS!&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Thu, 06 Nov 2025 11:30:02 +0000</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2025/11/06/self-hosting-your-mastodon-media-with-seaweedfs/</guid><category>freebsd</category><category>container</category><category>hosting</category><category>jail</category><category>networking</category><category>server</category><category>tutorial</category><category>web</category><category>fediverse</category><category>mastodon</category><category>ownyourdata</category><category>seaweedfs</category></item><item><title>FreeBSD vs. SmartOS: Who's Faster for Jails, Zones, and bhyve VMs?</title><link>https://it-notes.dragas.net/2025/09/19/freebsd-vs-smartos-whos-faster-for-jails-zones-bhyve/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/server_rack.webp" alt="A server rack with some servers and cables"&gt;&lt;/p&gt;&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;&lt;br /&gt;
These benchmarks were performed on my specific hardware and tuned for the workloads I expect to run.&lt;br /&gt;
They should not be taken as absolute or universally applicable results.&lt;br /&gt;
Different CPUs, storage, networking setups, or workload profiles could produce very different outcomes.&lt;br /&gt;
What I’m sharing here is a faithful snapshot of &lt;em&gt;my&lt;/em&gt; test environment and use case - a guidepost, not a final verdict.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Years ago, I installed a PCEngines APU at a client's site. It dutifully ran Proxmox with a few small VMs inside. It wasn't a speed demon, but it got the job done. Tasked with running in a closed, uncooled, and unsupervised server closet, it soldiered on for about seven years.&lt;/p&gt;
&lt;p&gt;Then, while I was at BSDCan, I got the call. A series of power outages and surges had finally taken their toll, and the APU was dead. It was probably just the power supply, but given its age, we decided it was time for a replacement. I set up a remote bypass to keep them running, but I knew I'd need to install something more powerful soon.&lt;/p&gt;
&lt;p&gt;I ordered a modern MiniPC-based on the low-power Intel Processor N150 platform, but with 16GB of RAM and more than enough performance to serve as a decent workstation. I have a similar one in my office running openSUSE Tumbleweed, and it works beautifully.&lt;/p&gt;
&lt;p&gt;This time, however, I decided to replace Proxmox with a different virtualization system. This decision wasn't made in a vacuum. In the past, I've put bhyve head-to-head with Proxmox, and my findings were clear: &lt;strong&gt;bhyve on FreeBSD is an extremely efficient hypervisor, &lt;a href="https://it-notes.dragas.net/2024/06/10/proxmox-vs-freebsd-which-virtualization-host-performs-better/"&gt;often outperforming KVM on Proxmox in my tests&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This positive experience is what made FreeBSD with bhyve a top contender. The other path was a KVM-style approach (which would require fewer changes to the VMs), where my options would be NetBSD or an &lt;a href="https://www.illumos.org/"&gt;illumos-based&lt;/a&gt; OS like &lt;a href="https://www.tritondatacenter.com/smartos"&gt;SmartOS&lt;/a&gt;. Since I had the new hardware on hand, I decided to run some tests to see how these different technologies stacked up against each other, and against the bare metal itself.&lt;/p&gt;
&lt;h3&gt;The Lineup: What I Put on the Test Bench&lt;/h3&gt;
&lt;p&gt;My goal was to test every reasonable option on this Intel N150 hardware. The final lineup covered the entire spectrum:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The Baseline:&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;FreeBSD 14.3-RELEASE Bare Metal:&lt;/strong&gt; The ground truth for performance on this hardware.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;OS-Level Virtualization (Containers):&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SmartOS Native Zone:&lt;/strong&gt; The baseline native container on SmartOS.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SmartOS LX Zone:&lt;/strong&gt; Running &lt;strong&gt;Ubuntu 24.04&lt;/strong&gt; and &lt;strong&gt;Alpine Linux&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FreeBSD Native Jail:&lt;/strong&gt; The baseline native container on FreeBSD.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FreeBSD Jail with Linux:&lt;/strong&gt; A jail running a &lt;strong&gt;Ubuntu 22.04&lt;/strong&gt; userland.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Full Hardware Virtualization (HVM):&lt;/strong&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;SmartOS bhyve Zone:&lt;/strong&gt; A FreeBSD guest inside the bhyve hypervisor on a SmartOS host.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SmartOS KVM Zone:&lt;/strong&gt; A FreeBSD guest inside the KVM hypervisor on a SmartOS host.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FreeBSD bhyve VM:&lt;/strong&gt; A FreeBSD guest inside the bhyve hypervisor on a FreeBSD host.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;The Benchmark: My &lt;code&gt;sysbench&lt;/code&gt; Commands&lt;/h3&gt;
&lt;p&gt;To keep the comparison fair and simple, I used two core &lt;code&gt;sysbench&lt;/code&gt; commands. To ensure consistency, I even compiled &lt;code&gt;sysbench&lt;/code&gt; from scratch on the SmartOS native zone to match the versions and compile options on the other systems as closely as possible.&lt;/p&gt;
&lt;p&gt;The commands I used in each environment were:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;For CPU performance:&lt;/strong&gt; &lt;code&gt;sysbench --test=cpu --cpu-max-prime=20000 run&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;For memory performance:&lt;/strong&gt; &lt;code&gt;sysbench --test=memory run&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;First Look: CPU and Memory on the Intel N150&lt;/h3&gt;
&lt;p&gt;My initial tests on the Intel N150 hardware immediately revealed some interesting trends. The &lt;code&gt;sysbench&lt;/code&gt; CPU results from any native FreeBSD environment (bare metal or jail) were on a completely different scale from the Linux and SmartOS guests, making a direct comparison meaningless.&lt;/p&gt;
&lt;p&gt;However, by excluding the incompatible FreeBSD-native results, we get a very clear picture of the overhead between the various container technologies.&lt;/p&gt;
&lt;h4&gt;Valid CPU Performance Comparison (Single Thread, Intel N150)&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left;"&gt;Host OS&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Container Tech&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Guest OS&lt;/th&gt;
&lt;th style="text-align: left;"&gt;CPU Performance (Events/sec)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;FreeBSD&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Jail (OS-level)&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Ubuntu 22.04&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;1108.18&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;SmartOS&lt;/td&gt;
&lt;td style="text-align: left;"&gt;LX Zone (OS-level)&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Ubuntu 24.04&lt;/td&gt;
&lt;td style="text-align: left;"&gt;1107.13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;SmartOS&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Native Zone (OS-level)&lt;/td&gt;
&lt;td style="text-align: left;"&gt;SmartOS&lt;/td&gt;
&lt;td style="text-align: left;"&gt;1107.04&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;SmartOS&lt;/td&gt;
&lt;td style="text-align: left;"&gt;LX Zone (OS-level)&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Alpine Linux&lt;/td&gt;
&lt;td style="text-align: left;"&gt;1022.81&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The takeaway here was clear: for CPU work, the overhead from these containers is basically a rounding error. For CPU-bound tasks, neither SmartOS Zones nor FreeBSD Jails will be a bottleneck.&lt;/p&gt;
&lt;p&gt;The memory results, which were consistent across all platforms, were far more revealing.&lt;/p&gt;
&lt;h4&gt;Overall Memory Performance Comparison (Intel Processor N150)&lt;/h4&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left;"&gt;Host OS&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Virtualization Tech&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Guest OS&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Memory Performance (Transfer Rate)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;SmartOS&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;LX Zone (OS-level)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Ubuntu 24.04&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;4970.54 MiB/sec&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;SmartOS&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Native Zone (OS-level)&lt;/td&gt;
&lt;td style="text-align: left;"&gt;SmartOS (Native)&lt;/td&gt;
&lt;td style="text-align: left;"&gt;4549.97 MiB/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;FreeBSD&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Jail (OS-level)&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Ubuntu 22.04&lt;/td&gt;
&lt;td style="text-align: left;"&gt;4348.32 MiB/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;FreeBSD&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Bare Metal&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;FreeBSD (Native)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;4005.08 MiB/sec&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;FreeBSD&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Native Jail (OS-level)&lt;/td&gt;
&lt;td style="text-align: left;"&gt;FreeBSD (Native)&lt;/td&gt;
&lt;td style="text-align: left;"&gt;3990.13 MiB/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;SmartOS&lt;/td&gt;
&lt;td style="text-align: left;"&gt;LX Zone (OS-level)&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Alpine Linux&lt;/td&gt;
&lt;td style="text-align: left;"&gt;3803.72 MiB/sec&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;FreeBSD&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;bhyve VM (Full HVM)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;FreeBSD&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;3636.01 MiB/sec&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;SmartOS&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;bhyve Zone (Full HVM)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;FreeBSD&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;3020.15 MiB/sec&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;SmartOS&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;KVM Zone (Full HVM)&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;FreeBSD&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;205.18 MiB/sec&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;These initial numbers led to a few conclusions: a virtual layer could be a performance boost, the userland matters, and bhyve clearly outclassed the legacy KVM on SmartOS. However, one result was nagging at me: the performance gap between FreeBSD bare metal (&lt;code&gt;4005.08 MiB/sec&lt;/code&gt;) and a native bhyve VM (&lt;code&gt;3636.01 MiB/sec&lt;/code&gt;) was about 9%. This was a larger drop than I expected. It prompted a new question: was this overhead inherent to bhyve, or was it a quirk of the new N150 hardware?&lt;/p&gt;
&lt;h3&gt;Going deeper: Testing on an Intel i7-7500U&lt;/h3&gt;
&lt;p&gt;To see if more mature, better-supported hardware would tell a different story, I replicated the FreeBSD tests on an older Qotom Mini-PC powered by an Intel i7-7500U. The results were illuminating and dramatically changed the narrative.&lt;/p&gt;
&lt;h4&gt;CPU Performance Comparison (Intel i7-7500U)&lt;/h4&gt;
&lt;p&gt;Once again, the CPU tests produced strange results. The native FreeBSD environments all reported incredibly high numbers in the millions of events/sec, while the Ubuntu Linuxulator jail's result was on a completely different, incompatible scale. Frankly, given the massive discrepancy between FreeBSD-native and Linux-based environments, I'm unsure that the &lt;code&gt;sysbench&lt;/code&gt; CPU figures can be considered totally reliable in absolute terms.&lt;/p&gt;
&lt;p&gt;However, what &lt;em&gt;is&lt;/em&gt; useful is comparing the native FreeBSD results against each other. This tells us about relative overhead.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left;"&gt;Platform&lt;/th&gt;
&lt;th style="text-align: left;"&gt;CPU Performance (Events/sec)&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Overhead vs. Bare Metal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;FreeBSD Bare Metal&lt;/td&gt;
&lt;td style="text-align: left;"&gt;6,377,778&lt;/td&gt;
&lt;td style="text-align: left;"&gt;Baseline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;FreeBSD Native Jail&lt;/td&gt;
&lt;td style="text-align: left;"&gt;6,379,271&lt;/td&gt;
&lt;td style="text-align: left;"&gt;~0.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;FreeBSD bhyve VM&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;6,346,852&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;-0.48%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Even if we're skeptical of the absolute numbers, the relative comparison is crystal clear: the CPU overhead of bhyve is &lt;strong&gt;less than half a percent&lt;/strong&gt;. This is the key takeaway.&lt;/p&gt;
&lt;h4&gt;Memory Performance Comparison (Intel i7-7500U)&lt;/h4&gt;
&lt;p&gt;The memory benchmarks, in contrast, were consistent and highly informative.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="text-align: left;"&gt;Platform&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Memory Performance (Transfer Rate)&lt;/th&gt;
&lt;th style="text-align: left;"&gt;Overhead vs. Bare Metal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;Ubuntu 22.04 Jail&lt;/td&gt;
&lt;td style="text-align: left;"&gt;4856.23 MiB/sec&lt;/td&gt;
&lt;td style="text-align: left;"&gt;+7.55%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;FreeBSD Native Jail&lt;/td&gt;
&lt;td style="text-align: left;"&gt;4517.73 MiB/sec&lt;/td&gt;
&lt;td style="text-align: left;"&gt;+0.05%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;FreeBSD Bare Metal&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;4515.24 MiB/sec&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;Baseline&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;FreeBSD bhyve VM&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;4491.60 MiB/sec&lt;/strong&gt;&lt;/td&gt;
&lt;td style="text-align: left;"&gt;&lt;strong&gt;-0.52%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;This is where the real story is. The memory performance of a bhyve VM was a mere &lt;strong&gt;0.52%&lt;/strong&gt; slower than bare metal. This is the kind of near-native performance one hopes for from a top-tier hypervisor and stands in stark contrast to the 9% drop seen on the newer N150.&lt;/p&gt;
&lt;h3&gt;Breaking Down the Results: What I Learned From Both Tests&lt;/h3&gt;
&lt;p&gt;This comprehensive two-platform analysis paints a much clearer picture.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Hardware &lt;em&gt;Really&lt;/em&gt; Matters&lt;/strong&gt;
Performance is not an absolute. The difference between the two platforms was stark: on the mature i7-7500U, bhyve’s overhead was less than 1%, while on the newer, budget N150, it was a more significant 9%. This suggests the performance dip is likely due to missing optimizations for that specific CPU architecture, rather than a fundamental flaw in bhyve itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. bhyve's True Potential is Near-Native Speed&lt;/strong&gt;
The i7 tests prove that &lt;strong&gt;bhyve is an exceptionally efficient hypervisor&lt;/strong&gt; on well-supported hardware. The relative CPU overhead was a negligible -0.48%, and more importantly, the reliable memory benchmarks showed a performance drop of just 0.52% compared to bare metal. This is the gold standard for virtualization.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. FreeBSD Jails are Feather-Light&lt;/strong&gt;
On both platforms, native FreeBSD jails demonstrated almost zero performance overhead. On the i7, both CPU and memory performance were virtually identical to bare metal (a 0.05% difference). The N150 CPU tests further showed that FreeBSD's container implementation is so efficient that running a Linux userland inside a jail delivered the best CPU scores of the entire lineup.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. SmartOS Zones Are Also Extremely Efficient&lt;/strong&gt;
Just like Jails, SmartOS's native Zones proved to be remarkably lightweight. The N150 CPU tests confirm this, showing that native and LX zones have virtually identical, top-tier performance. On the memory front, the native Zone delivered performance over 13% &lt;em&gt;faster&lt;/em&gt; than the FreeBSD bare-metal baseline, pointing to the high efficiency of the illumos kernel.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. The Linux Userland Excels at Throughput&lt;/strong&gt;
A clear pattern emerged on both testbeds: the Ubuntu userland consistently delivered excellent benchmark results. On the CPU front, Ubuntu on both FreeBSD and SmartOS delivered the highest, and nearly identical, performance scores on the N150. For memory, the story was even more dramatic: the Ubuntu LX Zone on SmartOS was the top performer, beating bare-metal FreeBSD by nearly 25%, while the Ubuntu jail on the i7 also surpassed its host by over 7%.&lt;/p&gt;
&lt;h3&gt;Final Thoughts: The Verdict for My Client's New Server&lt;/h3&gt;
&lt;p&gt;So, what's the bottom line for my client's new MiniPC? This benchmarking journey has made the path forward much clearer.&lt;/p&gt;
&lt;p&gt;At the beginning of this process, my main question was whether to stick with a KVM-based setup or make the switch to bhyve. The performance data answers that decisively. The legacy KVM on SmartOS showed a crippling performance penalty, making it a non-starter. Given that, the extra effort to migrate the existing VMs to a bhyve-compatible format is absolutely worth it. The performance gain is just too significant to ignore.&lt;/p&gt;
&lt;p&gt;The final question, then, is &lt;em&gt;which&lt;/em&gt; host OS to use for bhyve: SmartOS or FreeBSD? This is a much tougher call, as both platforms demonstrated incredible strengths.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;SmartOS&lt;/strong&gt;, powered by the illumos kernel, was a true surprise. It delivered astonishing performance on the target N150 hardware. Its key advantage is the raw speed of its containerization for both CPU and memory tasks. The Ubuntu LX Zone not only ran flawlessly but delivered top-tier CPU scores and outperformed the bare-metal FreeBSD baseline in memory by a massive 25% margin. This points to a highly efficient kernel and offers the tantalizing prospect of running ultra-fast Linux containers alongside performant bhyve VMs on the same host.&lt;/p&gt;
&lt;p&gt;On the other hand, &lt;strong&gt;FreeBSD&lt;/strong&gt; proved its mastery of bhyve virtualization. The tests on the i7 hardware showed its implementation to be the gold standard, offering virtually zero performance overhead for full hardware virtualization. Its native Jails are equally efficient, and its Linux compatibility layer is so effective that an Ubuntu jail delivered the &lt;em&gt;fastest CPU performance&lt;/em&gt; of all containers tested on FreeBSD. For workloads that &lt;em&gt;must&lt;/em&gt; live in a full VM, FreeBSD offers the most performant and native bhyve experience, with the reasonable expectation that its support for newer hardware like the N150 will only improve over time.&lt;/p&gt;
&lt;p&gt;Ultimately, the choice comes down to the primary workload. It's a decision between the raw container speed and Linux flexibility of SmartOS versus the pure, uncompromising HVM performance of FreeBSD.&lt;/p&gt;
&lt;p&gt;But one thing is certain: thanks to this deep dive, the path forward is much clearer, and it's paved by bhyve.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Fri, 19 Sep 2025 10:50:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2025/09/19/freebsd-vs-smartos-whos-faster-for-jails-zones-bhyve/</guid><category>freebsd</category><category>smartos</category><category>illumos</category><category>linux</category><category>jail</category><category>bhyve</category><category>zones</category><category>data</category><category>hosting</category><category>server</category><category>sysadmin</category><category>virtualization</category><category>ownyourdata</category></item><item><title>New Article on BSD Cafe Journal: WordPress on FreeBSD with BastilleBSD</title><link>https://it-notes.dragas.net/2025/07/21/new-article-wordpress-on-freebsd-bastillebsd-on-bsd-cafe-journal/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/web_text.webp" alt="Web Text - a terminal"&gt;&lt;/p&gt;&lt;h2&gt;New Article Published&lt;/h2&gt;
&lt;p&gt;I'm excited to announce that I have published a new, in-depth article on the &lt;strong&gt;&lt;a href="https://journal.bsd.cafe/"&gt;BSD Cafe Journal&lt;/a&gt;&lt;/strong&gt;: "&lt;a href="https://journal.bsd.cafe/2025/07/21/wordpress-on-freebsd-with-bastillebsd-a-secure-alternative-to-linux-docker/"&gt;WordPress on FreeBSD with BastilleBSD: A Secure Alternative to Linux/Docker&lt;/a&gt;".&lt;/p&gt;
&lt;p&gt;This piece explores how to create a robust and secure WordPress installation on FreeBSD using BastilleBSD, leveraging the power and isolation of FreeBSD &lt;a href="https://it-notes.dragas.net/categories/jail/"&gt;jails&lt;/a&gt; as a compelling alternative to the more common Linux and Docker stack.&lt;/p&gt;
&lt;h2&gt;Future Technical Content&lt;/h2&gt;
&lt;p&gt;I'm excited to announce that I'm expanding my writing to a new platform! From now on, some of my more technical, long-form articles and tutorials will be published on &lt;a href="https://journal.bsd.cafe"&gt;The BSD Cafe Journal&lt;/a&gt;, a fantastic hub for BSD-related content that I'm happy to now contribute to.&lt;/p&gt;
&lt;p&gt;This new collaboration complements the work I do here. My personal blog will continue to be my home base, and you won't miss a thing! I'll still be posting my own articles and announcements right here, and I'll always include a direct link to any new content I publish elsewhere. This space will remain as active as ever.&lt;/p&gt;
&lt;p&gt;Thank you for reading&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Mon, 21 Jul 2025 09:30:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2025/07/21/new-article-wordpress-on-freebsd-bastillebsd-on-bsd-cafe-journal/</guid><category>freebsd</category><category>container</category><category>sysadmin</category><category>hosting</category><category>jail</category><category>ownyourdata</category><category>server</category><category>tutorial</category><category>web</category><category>blogging</category></item><item><title>How to install FreeBSD on providers that don't support it with mfsBSD</title><link>https://it-notes.dragas.net/2025/07/02/install_freebsd_providers_mfsbsd/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/server_rack.webp" alt="FreeBSD installation on a server"&gt;&lt;/p&gt;&lt;p&gt;FreeBSD is an extremely powerful operating system. The ability to isolate services in jails and, thanks to ZFS, the simplicity with which you can create snapshots (both local and remote) make it a perfect system for increasing peace of mind, especially when running many workloads.&lt;/p&gt;
&lt;p&gt;Many providers, blinded by the success and large numbers achieved by Linux distributions, have decided to no longer support FreeBSD in their installers. While understandable (they might not have staff experienced with systems other than Linux), this can cause problems for those who want to try using something different. And yes, &lt;a href="https://it-notes.dragas.net/2025/03/23/osday-2025-why-choose-bsd-in-2025/"&gt;it makes perfect sense, even just to avoid IT monocultures&lt;/a&gt;, which are extremely harmful even in the medium term.&lt;/p&gt;
&lt;p&gt;There's an extremely powerful tool that, in my opinion, deserves much more attention than it gets. The tool is called &lt;a href="https://mfsbsd.vx.sk/"&gt;mfsBSD&lt;/a&gt;. Using the author's words: "This is a set of scripts that generates a bootable image (and/or ISO file), that creates a working minimal installation of FreeBSD (mfsBSD) or Linux (mfslinux). It is completely loaded into memory."&lt;/p&gt;
&lt;p&gt;mfsBSD works intelligently: it can be launched both via UEFI and via "traditional" boot since it has both boot modes enabled. It gets an IP address via DHCP (an operation that works with most providers) and opens an SSH server (with a preset password - so it's advisable to connect immediately and change it, to prevent someone else from doing it for you).&lt;/p&gt;
&lt;p&gt;This means that mfsBSD can be used both when you have a console available and, in many cases, without a console, since you'll just need to connect via SSH and start with the traditional installation.&lt;/p&gt;
&lt;p&gt;To install FreeBSD using mfsBSD, you just need to follow some very simple steps: all providers, in fact, offer the ability to boot in Linux "rescue mode", generally based on a sufficiently recent version. Set your server (whether physical or VPS, it doesn't matter) in rescue mode and reboot. Once active, connect via SSH (or open a console) to the server in rescue mode.&lt;/p&gt;
&lt;p&gt;Now it's sufficient to download the mfsBSD image (for example, from here: &lt;a href="https://mfsbsd.vx.sk/files/images/"&gt;https://mfsbsd.vx.sk/files/images/&lt;/a&gt; - in my case, I usually choose the normal image, which at the time of writing this post is https://mfsbsd.vx.sk/files/images/14/amd64/mfsbsd-14.2-RELEASE-amd64.img). At this point, it needs to be written with &lt;strong&gt;dd&lt;/strong&gt; directly to the server's disk (or, if in doubt, disks). For example:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;dd if=mfsbsd-14.2-RELEASE-amd64.img of=/dev/sda bs=5M conv=sync&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;In this case I wrote "sda", but if you had one or more NVMe drives, the correct device would be &lt;code&gt;/dev/nvme0n1&lt;/code&gt; for the first disk, &lt;code&gt;/dev/nvme1n1&lt;/code&gt; for the second, etc.&lt;/p&gt;
&lt;p&gt;Reboot. If you have a console, you'll see mfsBSD boot up, enable the SSH server, and position itself at the login. Otherwise, ping the server's IP until it starts responding. At that point, it's sufficient to connect via SSH as the root user.&lt;/p&gt;
&lt;p&gt;The root password is &lt;strong&gt;mfsroot&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;As the first thing, change the root password with the &lt;code&gt;passwd&lt;/code&gt; command&lt;/strong&gt;. This is to prevent someone from entering and compromising the machine during the installation time.&lt;/p&gt;
&lt;p&gt;Now launch the &lt;strong&gt;&lt;code&gt;bsdinstall&lt;/code&gt;&lt;/strong&gt; command and proceed with the normal FreeBSD installation (also setting up mirrors, RAID, etc., if desired), keeping in mind that mfsBSD is running in RAM, so you can overwrite the disk it was installed on without problems.&lt;/p&gt;
&lt;p&gt;After the reboot, you'll have your FreeBSD system installed and running.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Wed, 02 Jul 2025 09:30:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2025/07/02/install_freebsd_providers_mfsbsd/</guid><category>freebsd</category><category>hosting</category><category>server</category><category>vps</category><category>tutorial</category></item><item><title>FediMeteo: How a Tiny €4 FreeBSD VPS Became a Global Weather Service for Thousands</title><link>https://it-notes.dragas.net/2025/02/26/fedimeteo-how-a-tiny-freebsd-vps-became-a-global-weather-service-for-thousands/</link><description>&lt;p&gt;&lt;img src="https://unsplash.com/photos/ZVhm6rEKEX8/download?ixid=M3wxMjA3fDB8MXxhbGx8fHx8fHx8fHwxNzQwNTEzNjE5fA&amp;force=true&amp;w=640" alt="FediMeteo: How a Tiny €4 FreeBSD VPS Became a Global Weather Service for Thousands"&gt;&lt;/p&gt;&lt;h2&gt;Personal Introduction&lt;/h2&gt;
&lt;p&gt;Weather has always significantly influenced my life. When I was a young athlete, knowing the forecast in advance would have allowed me to better plan my training sessions. As I grew older, I could choose whether to go to school on my motorcycle or, for safety reasons, have my grandfather drive me. And it was him, my grandfather, who was my go-to meteorologist. He followed all weather patterns and forecasts, a remnant of his childhood in the countryside and his life on the move. It's to him that I dedicate &lt;a href="https://fedimeteo.com"&gt;FediMeteo&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The idea for &lt;a href="https://fedimeteo.com"&gt;FediMeteo&lt;/a&gt; started almost by chance while I was checking the holiday weather forecast to plan an outing. Suddenly, I thought how nice it would be to receive regular weather updates for my city directly in my timeline. After reflecting for a few minutes, I registered a domain and started planning.&lt;/p&gt;
&lt;h2&gt;Design Principles&lt;/h2&gt;
&lt;p&gt;The choice of operating system was almost automatic. The idea was to separate instances by country, and FreeBSD jails are one of the most useful tools for this purpose.&lt;/p&gt;
&lt;p&gt;I initially thought the project would generate little interest. I was wrong. After all, weather affects many of our lives, directly or indirectly. So I decided to structure everything in this way:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;I would use a test VPS to see how things would go. The VPS &lt;em&gt;was a small VM on a German provider with 4 shared cores, 4GB of RAM, 120GB of SSD disk space, and a 1Gbit/sec internet connection&lt;/em&gt; and now is a 4 euro per month VPS in Milano, Italy - 4 shared cores, 8 GB RAM and 75GB disk space.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;I would separate various countries into different instances, for both management and security reasons, as well as to have the possibility of relocating just some of them if needed.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;Weather data would come from a reliable and open-source friendly source. I narrowed it down to two options: &lt;a href="https://wttr.in/"&gt;wttr.in&lt;/a&gt; and &lt;a href="https://open-meteo.com/"&gt;Open-Meteo&lt;/a&gt;, two solutions I know and that have always given me reliable results.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;I would pay close attention to accessibility: forecasts would be in local languages, consultable via text browsers, with emojis to give an idea even to those who don't speak local languages, and everything would be accessible without JavaScript or other requirements. One's mother tongue is always more "familiar" than a second language, even if you're fluent.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;I would manage everything according to Unix philosophy: small pieces working together. The more years pass, the more I understand how valuable this approach is.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The software chosen to manage the instances is &lt;a href="https://codeberg.org/grunfink/snac2"&gt;snac&lt;/a&gt;. Snac embodies my philosophy of minimal and effective software, perfect for this purpose. It provides clear web pages for those who want to consult via the web, "speaks" the ActivityPub protocol perfectly, produces RSS feeds for each user (i.e., city), has extremely low RAM and CPU consumption, compiles in seconds, and is stable. The developer is an extremely helpful and positive person, and in my opinion, this carries equal weight as everything else.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;I would do it for myself. If there was no interest, I would have kept it running anyway, without expanding it. So no anxiety or fear of failure.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Technical Implementation&lt;/h2&gt;
&lt;p&gt;I started setting up the first "pieces" during the days around Christmas 2024. The scheme was clear: each jail would handle everything internally. A Python script would download data, city by city, and produce markdown. The city coordinates would be calculated via the &lt;a href="https://geopy.readthedocs.io/en/stable/"&gt;geopy&lt;/a&gt; library and passed to &lt;a href="https://wttr.in/"&gt;wttr.in&lt;/a&gt; and &lt;a href="https://open-meteo.com/"&gt;Open-Meteo&lt;/a&gt;. No data would be stored locally. This approach gives the ability to process all cities together. Just pass the city and country to the script, and the markdown would be served. At that point, snac comes into play: without the need to use external utilities, the "snac note" command allows posting from stdin by specifying the instance directory and the user to post from. No need to make API calls with external utilities, having to manage API keys, permissions, etc.&lt;/p&gt;
&lt;h3&gt;Setting Up for Italy&lt;/h3&gt;
&lt;p&gt;To simplify things, I first structured the jail for Italy. I made a list of the main cities, normalizing them. For example, La Spezia became la_spezia. Forlì, with an accent, became forli - this for maximum compatibility since each city would be a snac user. I then created a script that takes this list and creates snac users via "snac adduser." At that point, after creating all the users, the script would modify the JSON of each user to convert the city name to uppercase, insert the bio (a standard text), activate the "bot" flag, and set the avatar, which was the same for all users at the time. This script is also able to add a new city: just run the script with the (normalized) name of the city, and it will add it - also adding it to the "cities.txt" file, so it will be updated in the next weather update cycle.&lt;/p&gt;
&lt;h3&gt;Core Application Development&lt;/h3&gt;
&lt;p&gt;I then created the heart of the service. A Python application (initially only in Italian, then multilingual, separating the operational part from the text) able to receive (via command line) the name of a city and a country code (corresponding to the file with texts in the local language). The script determines the coordinates and then, using API calls, requests the current weather conditions, those for the next 12 hours, and the next 7 days. I conducted experiments with both wttr.in and Open-Meteo, and both gave good results. However, I settled on Open-Meteo because, for my uses, it has always provided very reliable results. This application directly provides an output in Markdown since snac supports it, at least partially.&lt;/p&gt;
&lt;p&gt;The cities.txt file is also crucial for updates. I created a script - post.sh, in pure sh, that scrolls through all cities, and for each one, launches the FediMeteo application and publishes its output using snac directly via command line. Once the job is finished, it makes a call to my instance of &lt;a href="https://it-notes.dragas.net/2024/07/22/install-uptime-kuma-freebsd-jail/"&gt;Uptime-Kuma&lt;/a&gt;, which keeps an eye on the situation. In case of failure, the monitoring will alert me that there have been no recent updates, and I can check.&lt;/p&gt;
&lt;p&gt;At this point, the system cron takes care of launching post.sh every 6 hours. The requests are serialized, so the cities will update one at a time, and the posts will be sent to followers.&lt;/p&gt;
&lt;h2&gt;Growth and Unexpected Success&lt;/h2&gt;
&lt;p&gt;After listing all Italian provincial capitals, I started testing everything. It worked perfectly. Of course, I had to make some adjustments at all levels. For example, one of the problems encountered was that snac did not set the language of the posts, and some users could have missed them. The developer was very quick and, as soon as I exposed the problem, immediately modified the program so that the post could keep the system language, set as an environment variable in the sh script.&lt;/p&gt;
&lt;p&gt;After two days, I decided to start adding other countries and announce the project. And the announcement was unexpectedly well received: there were many boosts, and people started asking me to add their cities or countries. I tried to do what I could, within the limits of my physical condition, as in those days, I had the flu that kept me at home with a fever and illness for several days. I started adding many countries in the heart of Europe, translating the main indications into local languages but maintaining emojis so that everything would be understandable even to those who don't speak the local language. There were some small problems reported by some users. One of them: not all weather conditions had been translated, so sometimes they appeared in Italian - as well as errors. In bilingual countries, I tried to include all local languages. Sometimes, unfortunately, making mistakes as I encountered dynamics unknown to me or difficult to interpret. For example, in Ireland, forecasts were published in Irish, but it was pointed out to me that not everyone speaks it, so I modified and published in English.&lt;/p&gt;
&lt;h3&gt;A Turning Point&lt;/h3&gt;
&lt;p&gt;The turning point was when FediFollows (&lt;a href="https://social.growyourown.services/@FediFollows"&gt;@FediFollows@social.growyourown.services&lt;/a&gt; - who also manages the site &lt;a href="https://fedi.directory/"&gt;Fedi Directory&lt;/a&gt;) started publishing the list of countries and cities, highlighting the project. Many people became aware of FediMeteo and started following the various accounts, the various cities. And from here came requests to add new countries and some new information, such as wind speed. Moreover, I was asked (rightly, to avoid flooding timelines) to publish posts as unlisted - this way, followers would see the posts, but they wouldn't fill local timelines. Snac didn't support this, but again, the snac dev came to my rescue in a few hours.&lt;/p&gt;
&lt;h2&gt;Scaling Challenges&lt;/h2&gt;
&lt;p&gt;But with new countries came new challenges. For example, in my original implementation, all units of measurement were in metric/decimal/Celsius - and this doesn't adapt well to realities like the USA. Moreover, focusing on Europe, almost all countries were located in a single timezone, while for larger countries (such as Australia, USA, Canada, etc.), this is totally different. So I started developing a more complete and global version and, in the meantime, added almost all of Europe. The new version would have to be backward compatible, would have to take into account timezone differences for each city, different measurements (e.g., degrees C and F), as well as, initially more difficult part, being able to separate cities with the same name based on states or provinces. I had already seen a similar problem with the implementation of support for Germany, so it had to be addressed properly.&lt;/p&gt;
&lt;p&gt;The original goal was to have a VPS for each continent, but I soon realized that thanks to the quality of snac's code and FreeBSD's efficient management, even keeping countries in separate jails, the load didn't increase much. So I decided to challenge myself and the limits of the economical 4 euros per month VPS. That is, to insert as much as possible until seeing what the limits were. Limits that, to date, I have not yet reached. I would also soon exhaust the available API calls for Open-Meteo's free accounts, so I tried to contact the team and explain everything. I was positively surprised to read that they appreciated the project and provided me with a dedicated API key.&lt;/p&gt;
&lt;p&gt;Compatible with my free time, I managed to complete the richer and more complete version of my Python program. I'm not a professional dev, I'm more oriented towards systems, so the code is probably quite poor in the eyes of an expert dev. But, in the end, it just needs to take an input and give me an output. It's not a daemon, it's not a service that responds on the network. For that, snac takes care of it.&lt;/p&gt;
&lt;h2&gt;Expansion to North America&lt;/h2&gt;
&lt;p&gt;So I decided to start with a very important launch: the USA and Canada. A non-trivial part was identifying the main cities in order to cover, state by state, all the territory. In the end, I identified more than 1200 cities. A number that, by itself, exceeded the sum of all other countries (at that time). And the program, now, is able to take an input with a separator (two underscores: __) between city and state. In this way, it's possible to perfectly understand the differences between city and state: new_york__new_york is an example I like to make, but there are many.&lt;/p&gt;
&lt;p&gt;The launch of the USA was interesting: despite having had many previous requests, the reception was initially quite lukewarm, to my extreme surprise. The number of followers in Canada, in a few hours, far exceeded that of the USA. On the contrary, the country with the most followers (in a few days, more than 1000) was Germany. Followed by the UK - which I expected would have been the first.&lt;/p&gt;
&lt;h2&gt;System Performance&lt;/h2&gt;
&lt;p&gt;The VPS held up well. Except for the moments when FediFollows launched (after fixing some FreeBSD tuning, the service slowed slightly but didn't crash), the load remained extremely low. So I continued to expand: Japan, Australia, New Zealand, etc.&lt;/p&gt;
&lt;h2&gt;Current Status&lt;/h2&gt;
&lt;p&gt;At the time of the last update of this article (21 May 2026), the supported countries are 42: Argentina, Australia, Austria, Belgium, Brazil, Bulgaria, Canada, Cyprus, Croatia, Czechia, Denmark, Estonia, Finland, France, Germany, Greece, Hungary, India, Indonesia, Ireland, Italy, Japan, Latvia, Lithuania, Malta, Mexico, Netherlands, New Zealand, Norway, Poland, Portugal, Romania, Slovakia, Slovenia, South Africa, Spain, Sweden, Switzerland, Taiwan, Turkey, the United Kingdom, and the United States of America (with more regions coming soon!).&lt;/p&gt;
&lt;p&gt;Direct followers in the Fediverse are around 8,889 and growing daily, excluding those who follow hashtags or cities via RSS, whose number I can't estimate. However, a quick look at the logs suggests there are many more.&lt;/p&gt;
&lt;p&gt;The cities currently covered are 3602 - growing based on new countries and requests.&lt;/p&gt;
&lt;h2&gt;Challenges Encountered&lt;/h2&gt;
&lt;p&gt;There have been some problems. The most serious, by my fault, was the API key leak: I had left a debug code active and, the first time Open-Meteo had problems, the error message also included the API call - including the API key. Some users reported it to me (others just mocked) and I fixed the code and immediately reported everything to the Open-Meteo team, who kindly gave me a new API Key and deactivated the old one.&lt;/p&gt;
&lt;p&gt;A further problem was related to geopy. It makes a call to Nominatim to determine coordinates. One of the times Nominatim didn't respond, my program wasn't able to determine the position and went into error. I solved this by introducing coordinate caching: now the program, the first time it encounters a city, requests and saves the coordinates. If present, they will be used in the future without making a new request via geopy. This is both lighter on their servers and faster and safer for us.&lt;/p&gt;
&lt;h2&gt;Infrastructure Details&lt;/h2&gt;
&lt;p&gt;And the VPS? It has no problems and is surprisingly fast and effective. FreeBSD 15.0-RELEASE, BastilleBSD to manage the jails. Currently, there are 43 jails - one for haproxy, the &lt;a href="https://fedimeteo.com"&gt;FediMeteo website&lt;/a&gt;, so nginx, and the snac instance for &lt;a href="https://fedimeteo.com/fedi/admin"&gt;FediMeteo announcements and support&lt;/a&gt; - the other 41 for the individual instances. Each of them, therefore, has its autonomous ZFS dataset. Every 15 minutes, there is a local snapshot of all datasets. Every hour, the homepage is regenerated: a small script calculates the number of followers (counting, instance by instance, the followers of individual cities, since I don't publish except in aggregate to avoid possible triangulations and privacy leaks of users). Every hour, moreover, an external backup is made via &lt;a href="https://it-notes.dragas.net/2022/05/30/how-we-are-migrating-many-of-our-servers-from-linux-to-freebsd-part-2/"&gt;zfs-autobackup&lt;/a&gt; (on encrypted at rest dataset), and once a day, a further backup is made in my datacenter, on disks encrypted with geli. The occupied RAM is 501 MB (yes, exactly: 501 MB), which rises slightly when updates are in progress. Updates normally occur every 6 hours. I have tried, as much as possible, to space them out to avoid overloads in timelines (or on the server itself). Only for the USA, I added a sleep of 5 seconds between one city and another, to give snac the opportunity to better organize the sending of messages. It probably wouldn't be necessary, with the current numbers, but better safe than sorry. In this way, the USA is processed in about 2 and a half hours, but the other jails (thus countries) can work autonomously and send their updates.&lt;/p&gt;
&lt;p&gt;The average load of the VPS (taking as reference both the last 24 hours and the last two weeks) is about 25%, as it rises to 70/75% when updates occur for larger instances (such as the USA), or when it is announced by FediFollows. Otherwise, it is on average less than 10%. So, the VPS still has huge margin, and new instances, with new nations, will still be inside it.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;This article, although in some parts very conversational, aims to demonstrate how it's possible to build solid, valid, and efficient solutions without the need to use expensive and complex services. Moreover, this is the demonstration of how it's possible to have your online presence without the need to put your data in the hands of third parties or without necessarily having to resort to complex stacks. Sometimes, less is more.&lt;/p&gt;
&lt;p&gt;The success of this project demonstrates, once again, that my grandfather was right: weather forecasts interest everyone. He worried about my health and, thanks to his concerns, we spent time together. In the same way, I see many followers and friends talking to me or among themselves about the weather, their experiences, what happens. Again, in my life, weather forecasts have helped sociality and socialization.&lt;/p&gt;
&lt;p&gt;Thank you, Grandpa.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Wed, 26 Feb 2025 07:00:00 +0100</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2025/02/26/fedimeteo-how-a-tiny-freebsd-vps-became-a-global-weather-service-for-thousands/</guid><category>fediverse</category><category>snac</category><category>snac2</category><category>hosting</category><category>server</category><category>freebsd</category><category>networking</category><category>web</category><category>social</category><category>fedimeteo</category></item><item><title>Caching snac Proxied Media with Nginx</title><link>https://it-notes.dragas.net/2025/02/08/caching-snac-proxied-media-with-nginx/</link><description>&lt;p&gt;&lt;img src="https://unsplash.com/photos/9Xf-jxvfpW8/download?ixid=M3wxMjA3fDB8MXxhbGx8MXx8fHx8fHx8MTc0MTU5MTUzNXw&amp;force=true&amp;w=1920" alt="Photo by &amp;lt;a href=&amp;quot;https://unsplash.com/it/@elenarossini&amp;quot;&amp;gt;Elena Rossini&amp;lt;/a&amp;gt; on Unsplash"&gt;&lt;/p&gt;&lt;p&gt;One of the useful yet resource-intensive features of platforms like Mastodon is that they reprocess (and store locally) all multimedia files from other instances.&lt;/p&gt;
&lt;p&gt;This behavior is implemented for three valid reasons:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;To ensure files do not contain malicious code by reprocessing them locally.&lt;/li&gt;
&lt;li&gt;To prevent all users from all instances from overloading the original instance hosting the media by requesting it repeatedly.&lt;/li&gt;
&lt;li&gt;To hide the individual IP addresses of users from the original instance.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;While this approach has benefits, it also requires significant disk space - often many gigabytes per day.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://codeberg.org/grunfink/snac2"&gt;snac&lt;/a&gt;, on the other hand, does not locally process or cache media by default. Instead, media URLs remain unchanged, meaning users will fetch content directly from its original source. This behavior is perfectly fine for many setups (such as mobile devices), but it may not be suitable for everyone.&lt;/p&gt;
&lt;p&gt;I suggested a small improvement to snac’s developer, who immediately recognized the benefit for users and implemented an interesting new feature: media proxying via the instance itself.&lt;/p&gt;
&lt;h3&gt;Enabling Media Proxying in snac&lt;/h3&gt;
&lt;p&gt;To enable this feature, simply add the following line to your &lt;code&gt;server.json&lt;/code&gt; configuration file:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-json"&gt;&amp;quot;proxy_media&amp;quot;: true
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Once enabled, snac will rewrite all media URLs to pass through its own instance. This ensures that original instances will no longer see the IP addresses of individual users, as they will only see the IP of the snac instance itself.&lt;/p&gt;
&lt;p&gt;However, this also increases the load and bandwidth consumption of the instance since it must download and forward media every time a user accesses it.&lt;/p&gt;
&lt;h3&gt;Caching Proxied Media with nginx&lt;/h3&gt;
&lt;p&gt;To optimize performance, we can configure nginx to cache these proxied files transparently. This way, if multiple users from the snac instance (or the same user at different times) request the same media file, nginx will serve it from the local cache instead of fetching it again. This setup has two key advantages:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Reduces traffic&lt;/strong&gt; to the original instances.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lowers the load&lt;/strong&gt; on the snac instance, as it won’t need to download the same file repeatedly.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To achieve this, create a cache storage area in nginx by adding the following to the &lt;code&gt;http&lt;/code&gt; section:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-conf"&gt;proxy_cache_path /var/cache/nginx/snac_media levels=1:2 keys_zone=snac_media:10m max_size=1g 
                 inactive=1d use_temp_path=off;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This defines a cache directory at &lt;code&gt;/var/cache/nginx/snac_media&lt;/code&gt;, with 10 MB allocated for metadata and a maximum cache size of 1 GB. Cached content will be invalidated and removed after one day, ensuring frequently accessed content (like profile avatars) remains cached while rarely used files get replaced over time.&lt;/p&gt;
&lt;p&gt;Next, add the following rule to your snac instance's virtual host configuration:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-conf"&gt;location ~ ^/.+/(x|y)/ {
    proxy_cache snac_media;
    proxy_pass http://snac-ip:8001;
    proxy_set_header Host $host;
    proxy_cache_valid 200 1d;
    proxy_cache_valid 404 1h;
    proxy_ignore_headers &amp;quot;Cache-Control&amp;quot; &amp;quot;Expires&amp;quot;;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_lock on;
    add_header X-Proxy-Cache $upstream_cache_status;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;After reloading nginx, the cache will start populating as users request media files. Unrequested content will never enter the cache, ensuring efficient storage usage without unnecessary clutter.&lt;/p&gt;
&lt;p&gt;By combining snac’s new media proxying feature with nginx caching, we can achieve a more balanced setup-reducing load on both our instance and external ones, improving privacy, and enhancing performance.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Sat, 08 Feb 2025 16:00:00 +0100</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2025/02/08/caching-snac-proxied-media-with-nginx/</guid><category>snac</category><category>snac2</category><category>fediverse</category><category>nginx</category><category>data</category><category>hosting</category><category>server</category><category>tutorial</category><category>ownyourdata</category><category>networking</category><category>web</category><category>tipsandtricks</category><category>social</category></item><item><title>Improving snac Performance with Nginx Proxy Cache</title><link>https://it-notes.dragas.net/2025/01/29/improving-snac-performance-with-nginx-proxy-cache/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/web_text.webp" alt="Improving snac Performance with Nginx Proxy Cache"&gt;&lt;/p&gt;&lt;p&gt;Some days ago, I migrated my personal Fediverse instance from &lt;a href="https://akkoma.social/"&gt;Akkoma&lt;/a&gt; to &lt;a href="https://codeberg.org/grunfink/snac2"&gt;snac&lt;/a&gt;. I appreciate snac a lot and believe it is the best solution available for many use cases.&lt;/p&gt;
&lt;p&gt;Akkoma is an excellent tool, but I noticed that even for a small instance like mine, the database grows exponentially, and the database activity remains constant. Despite low load, my disks are continuously "flashing" - which isn't a problem in itself but clearly indicates ongoing activity. In my case, this activity seems unnecessary since it's just a single-user instance.&lt;/p&gt;
&lt;p&gt;snac has shown excellent capabilities for managing the &lt;a href="https://fedimeteo.com"&gt;FediMeteo&lt;/a&gt; project (which I'll write about in detail soon), is lightweight, and has very few dependencies. Moreover, a dedicated snac instance handles sending updates from this blog to the Fediverse.&lt;/p&gt;
&lt;p&gt;After successfully transferring my followers from Akkoma to snac without major issues, I started using the new instance. However, as soon as I posted a photo (approximately 4MB), something happened that I somewhat expected but in a different form. My home internet connection (upload speed: 20 Mbit/sec) became saturated, but I also noticed that new connections and smaller entities were resulting in 499 errors - meaning nginx couldn't open new connections to snac. After some investigation, I realized the reason: for every remote instance, Nginx was requesting the multimedia file from snac. Due to saturated connections (snac allows setting the maximum number of active threads), it took several seconds, leading to thread exhaustion in snac. Consequently, subsequent nginx requests resulted in 499 errors as snac could no longer allocate a thread.&lt;/p&gt;
&lt;p&gt;To resolve this, I decided to implement direct caching using nginx. My reverse proxy (running in a different FreeBSD jail but this doesn't change the outcome) can cache multimedia files - storing them on first request and serving them directly to everyone who requests them without needing to ask snac every time. This approach is similar to &lt;a href="https://it-notes.dragas.net/2024/08/26/building-a-self-hosted-cdn-for-bsd-cafe-media/"&gt;what I use for the media in the BSD Cafe's Mastodon instance&lt;/a&gt;, where I employ &lt;a href="https://varnish-cache.org/"&gt;Varnish&lt;/a&gt; to keep everything in RAM.&lt;/p&gt;
&lt;p&gt;In snac, images and multimedia files are served from a specific path, such as: https://example.com/user/s/filename.png&lt;/p&gt;
&lt;p&gt;The key here is the &lt;em&gt;/s/&lt;/em&gt; segment. By instructing nginx to cache all files containing /s/ in their URL, I can offload some of the work from snac.&lt;/p&gt;
&lt;p&gt;I modified my &lt;code&gt;nginx.conf&lt;/code&gt; file to include a caching setup:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-conf"&gt;# Caching configuration for snac
proxy_cache_path /var/cache/nginx/snac_cache levels=1:2 keys_zone=snac:10m max_size=1g inactive=1440m use_temp_path=off;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This creates a section for &lt;code&gt;/var/cache/nginx&lt;/code&gt; where I define the caching parameters. It will allocate 10 MB of RAM for metadata, with a maximum cache size of 1 GB (useful if you decide to post some videos). Cached content will be considered invalid and removed after 1440 minutes (24 hours). This ensures that frequently accessed content (like profile avatars and banners) remains in the cache while less frequently accessed content can be replaced with newer content. The goal isn't to have all content cached but rather for nginx to serve files independently during peak times, preventing multiple remote instances from overwhelming snac simultaneously.&lt;/p&gt;
&lt;p&gt;In the virtual host configuration for snac, I added this specific override for multimedia content:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-conf"&gt;# Caching rules for /s/ path
location ~ ^/.+/s/ {
    proxy_cache snac;
    proxy_pass http://snac-jail-ip:8001;
    proxy_set_header Host $host;
    proxy_cache_valid 200 1d;
    proxy_cache_valid 404 1h;
    proxy_ignore_headers &amp;quot;Cache-Control&amp;quot; &amp;quot;Expires&amp;quot;;
    proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
    proxy_cache_lock on;
    add_header X-Proxy-Cache $upstream_cache_status;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;After restarting nginx, the multimedia files will be cached on their first access and served by nginx, leaving snac's threads free to handle everything else. This setup ensures smoother performance and prevents resource exhaustion during periods of high activity or when new content is shared.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Wed, 29 Jan 2025 09:00:00 +0100</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2025/01/29/improving-snac-performance-with-nginx-proxy-cache/</guid><category>snac</category><category>snac2</category><category>fediverse</category><category>nginx</category><category>data</category><category>hosting</category><category>server</category><category>freebsd</category><category>tutorial</category><category>ownyourdata</category><category>networking</category><category>web</category><category>tipsandtricks</category><category>social</category></item><item><title>Outdated Infrastructure and the Cloud Illusion</title><link>https://it-notes.dragas.net/2024/10/19/outdated-infrastructure-and-the-cloud-illusion/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/datacenter.webp" alt="Outdated Infrastructure and the Cloud Illusion"&gt;&lt;/p&gt;&lt;p&gt;Yesterday, I received a phone call.&lt;/p&gt;
&lt;p&gt;It was from a client I worked with years ago, but I had lost contact with them in early 2020. Back then, I was told about an ongoing migration “to the cloud.” I gave my opinions (they have poor connectivity and a high need for reliability without significant investment, since they are a healthcare facility, and dozens of people, besides patients, would be stuck if services became unreachable). In 2015, I had set up twin local servers for them, syncing the VMs every 5 minutes to be ready to switch without the full burden of true HA. The setup was based on Proxmox, with daily backups to another location.&lt;/p&gt;
&lt;p&gt;Yesterday morning, the internal person responsible for this called me in a panic. In one phone call, I discovered that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;They are still using that server - without any updates since 2019 (Proxmox 5.x).&lt;/li&gt;
&lt;li&gt;They removed the twin server because, according to this person, it was “useless.”&lt;/li&gt;
&lt;li&gt;They are using a Windows 2008r2 VPS in production as a main server and a VPS with Windows XP (!!!) to manage some external operations. This VPS “exploded” due to an error by one of their staff (!!!), and they don’t know if there are any backups or how to restore them.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these machines are accessible externally, but all are on a LAN with hundreds of devices. All on the same LAN.&lt;/p&gt;
&lt;p&gt;I (politely) exploded, having been convinced for years that this server had been decommissioned. I had already stressed in 2020 the need to update the underlying infrastructure, explaining that the physical server dates back to 2015, that you can’t run such old software on Windows 2008r2 and Windows XP, and that Proxmox 5 has been EOL for ages. But they responded, “we are allocating funds for the new cloud setup,” even though their connectivity is unstable and the costs would be absurd.&lt;/p&gt;
&lt;p&gt;I decided to help them anyway because I was thinking about the dozens of sick people stuck and waiting for services to be restored. I connected via VPN they provided, and luckily, I discovered that the backups were still happening daily (just as I had set them up years ago and last checked in 2019) and that they were intact. I restored the (small) Windows XP VM from the backup of the previous day, and they resumed working.&lt;/p&gt;
&lt;p&gt;After resolving the emergency, I called back and explained that such a situation is inconceivable. They replied that they are still “planning the migration to the new cloud system” but need to adjust connectivity, configurations, software, VPN, etc. After 5 years. At that point, I lost my patience and, courteously but firmly, emphasized that while they are “dreaming” of a “cloud” they will never achieve (and have already paid tens of thousands for “design”), their entire operation runs on a stable setup that just needs to be updated. They have 8 locations, and now I discover they’re all like this. At any moment, an unsolvable problem could arise. But no use: all their IT funds are diverted to the “study and realization of the new cloud solution” - which, by their own admission, won’t be ready anytime soon. Maybe in a couple of years, maybe. I tried to alert the owners as well, but they insisted, “the project has started, a lot has been spent, and it cannot be stopped. And there are no funds for any further spending on the current infrastructure, not even 500 euros.”&lt;/p&gt;
&lt;p&gt;I explained that, given their internal and external situation (non-excellent and unstable connectivity, without any prospect of improvement, especially for 2 sites), they need a local copy of the data, processed locally, and then, for security, always copied externally. But to no avail. Their “cloud consultant” has been “working hard” for years to migrate everything, but there are “many issues,” and it will take “time and money.” And unfortunately, they are following that path.&lt;/p&gt;
&lt;p&gt;I wonder when we will realize that blindly following trends and hype can be (besides unnecessarily expensive) truly, truly dangerous.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Sat, 19 Oct 2024 11:28:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2024/10/19/outdated-infrastructure-and-the-cloud-illusion/</guid><category>server</category><category>ownyourdata</category><category>hosting</category><category>virtualization</category><category>proxmox</category><category>recovery</category><category>ha</category></item><item><title>Increasing or Modifying Character Limits and Poll Options in Mastodon 4.3, 4.4, 4.5, 4.6</title><link>https://it-notes.dragas.net/2024/10/09/2024-modifying-limits-in-mastodon-4-3/</link><description>&lt;p&gt;&lt;img src="https://imgcdn.agendadigitale.eu/wp-content/uploads/2022/11/21142557/welcome-mastodon.jpeg.webp" alt="Increasing or Modifying Character Limits and Poll Options in Mastodon 4.3, 4.4, 4.5, 4.6"&gt;&lt;/p&gt;&lt;p&gt;&lt;strong&gt;Tested with Mastodon 4.6.x&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.joinmastodon.org/2024/10/mastodon-4.3/"&gt;Mastodon 4.3.0 was released just a few hours ago&lt;/a&gt;, and many instances have already started updating.&lt;/p&gt;
&lt;p&gt;During my tests, I noticed that some things have changed in the core files, and the procedures normally described for changing the character limit of posts no longer work. So, I decided to write this short blog post to document the updated procedure.&lt;/p&gt;
&lt;h3&gt;Increasing or Modifying the Character Limit&lt;/h3&gt;
&lt;p&gt;At &lt;a href="https://mastodon.bsd.cafe"&gt;BSD Cafe&lt;/a&gt;, the character limit is set to 5000 (up from the 500 originally allowed by Mastodon). No one has ever used all the available characters, but I prefer that the limit be decided by the poster, not by the platform.&lt;/p&gt;
&lt;p&gt;To customize this limit, you need to modify two files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;live/app/javascript/mastodon/features/compose/containers/compose_form_container.js&lt;/code&gt; - find the line that contains:&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;maxChars: state.getIn(['server', 'server', 'configuration', 'statuses', 'max_characters'], 500),&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;and change &lt;code&gt;500&lt;/code&gt; to your desired value.&lt;/p&gt;
&lt;p&gt;Next, you need to modify another file: &lt;code&gt;live/app/validators/status_length_validator.rb&lt;/code&gt; - find the line that contains:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MAX_CHARS = 500&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;and change it accordingly.&lt;/p&gt;
&lt;p&gt;Once done, run the following command:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;RAILS_ENV=production bundle exec rails assets:precompile&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;and restart the Mastodon services to apply the changes.&lt;/p&gt;
&lt;h3&gt;Increasing or Modifying the Number of Poll Options&lt;/h3&gt;
&lt;p&gt;Edit the file &lt;code&gt;live/app/validators/poll_options_validator.rb&lt;/code&gt; and modify the line:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;MAX_OPTIONS      = 4&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;to the value you want. There are also other customizable options in this file.&lt;/p&gt;
&lt;p&gt;Again, once done, run the following command:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;RAILS_ENV=production bundle exec rails assets:precompile&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;and restart the Mastodon services to apply the changes.&lt;/p&gt;
&lt;p&gt;Changes might not be immediately visible because of caching (browser, web server, etc.), but once these caches expire, the new limits will take effect.&lt;/p&gt;
&lt;p&gt;These modifications might be overridden by subsequent Mastodon updates, so be sure to check after every update that they are still valid.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Wed, 09 Oct 2024 14:53:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2024/10/09/2024-modifying-limits-in-mastodon-4-3/</guid><category>mastodon</category><category>ownyourdata</category><category>fediverse</category><category>hosting</category><category>social</category><category>web</category></item><item><title>Using a Permanent WebFinger Address for My Fediverse Profile</title><link>https://it-notes.dragas.net/2024/10/08/using-a-permanent-webfinger-address/</link><description>&lt;p&gt;&lt;img src="https://unsplash.com/photos/9Xf-jxvfpW8/download?ixid=M3wxMjA3fDB8MXxhbGx8MXx8fHx8fHx8MTc0MTU5MTUzNXw&amp;force=true&amp;w=1920" alt="Photo by &amp;lt;a href=&amp;quot;https://unsplash.com/it/@elenarossini&amp;quot;&amp;gt;Elena Rossini&amp;lt;/a&amp;gt; on Unsplash"&gt;&lt;/p&gt;&lt;p&gt;Decentralized technologies have always been of great interest to me. In my opinion, the decentralization of the Internet was one of the keys to its success, but year after year, we are gradually losing it. This is happening because we increasingly rely on "mainstream" services for convenience, habit, or trend.&lt;/p&gt;
&lt;p&gt;I often hear colleagues say, "I've put my client's email on Y (Y = one of the major players in the email world) to avoid problems." The problems remain, but the colleague can "pass the buck" to the big player of the moment. And, in my experience, clients tend to be much more forgiving when the problem is with a big player.&lt;/p&gt;
&lt;p&gt;Once, people used to say: "Nobody ever got fired for buying IBM." Today, I would say, "Nobody ever got fired for putting email on [Google, Microsoft, etc.]."&lt;/p&gt;
&lt;p&gt;A few days ago, a colleague mentioned that he struggles to find some of his friends and colleagues on the fediverse because they are spread across various instances, and a simple search doesn't always yield the desired results.&lt;/p&gt;
&lt;h3&gt;Own Your Data&lt;/h3&gt;
&lt;p&gt;The fediverse is an extremely effective, resilient, autonomous decentralized communication technology. Unlike email, the many software implementations (such as Mastodon, GoToSocial, Mitra, Akkoma, Pleroma, etc. - soon snac will also support this type of operation) support the ability to "move" one's account. This means that when a user decides to relocate, the server will notify all followers that the user has moved. The followers will automatically stop following the original account and start following the new one. As of today, it is not possible to move past content (which will still be available on the old server).&lt;/p&gt;
&lt;p&gt;In my opinion, every government, public entity, association, foundation, etc., that needs to communicate with the public should have its own communication channel, with full control over its data and the messages it delivers. When I read "my Discord server," I feel like responding "there's nothing 'yours' about it - tomorrow morning they could shut everything down, and you would have lost EVERYTHING.". &lt;strong&gt;Own your data!&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Sometimes, I read that instances are not opened because of "costs not balanced by the number of users." But even public television channels are often economically unprofitable, yet they are considered an essential service for public communication. Open, decentralized technologies that ensure control over one's data should be treated the same way.&lt;/p&gt;
&lt;p&gt;The problem, as with email, is tied to the address. A server change necessarily implies an address change, which can sometimes cause problems.&lt;/p&gt;
&lt;h3&gt;A Permanent Address&lt;/h3&gt;
&lt;p&gt;For this reason, I decided to implement a kind of "permanent" address that will provide the correct data based on my main server. I decided that this address should be linked to my email address, and since I control the entire domain (including the web server), I can do this quite simply. In this way, if I change servers, users who already follow me will be moved by the "move" operation, while those who do not yet follow me will still be able to find me using my "permanent" address, which will, in turn, provide the new user on the new server.&lt;/p&gt;
&lt;p&gt;My setup will be very similar to what I found in &lt;a href="https://fnordig.de/2023/01/02/serving-webfinger-resources-with-nginx/"&gt;an interesting blog post&lt;/a&gt; that I based my approach on.&lt;/p&gt;
&lt;h3&gt;In Practice&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;My email address is &lt;code&gt;stefano@dragas.it&lt;/code&gt;, and I want anyone from any fediverse instance to be able to type it in and find me.&lt;/li&gt;
&lt;li&gt;My main account is currently on &lt;a href="https://bsd.cafe"&gt;BSD Cafe&lt;/a&gt;: &lt;a href="https://mastodon.bsd.cafe/@stefano"&gt;https://mastodon.bsd.cafe/@stefano&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To achieve this, I modified the nginx configuration on the server that serves the "dragas.it" website.&lt;/p&gt;
&lt;h3&gt;Nginx Configuration&lt;/h3&gt;
&lt;p&gt;Before the &lt;code&gt;server&lt;/code&gt; directive for dragas.it, I added this mapping:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;map $query_string $account_name {
    ~resource=acct:stefano@dragas.it$ stefano;
    ~resource=acct%3Astefano%40dragas.it$ stefano;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This ensures that requests for those two resources (some clients send the second one, so I wanted to handle that situation) will assign the value "stefano" to the &lt;code&gt;$account_name&lt;/code&gt; variable.&lt;/p&gt;
&lt;h3&gt;WebFinger Exception&lt;/h3&gt;
&lt;p&gt;Within the &lt;code&gt;server&lt;/code&gt; directive of the dragas.it virtual host, I added an exception for webfinger (the protocol used to find user data):&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-nginx"&gt;location = /.well-known/webfinger {
    root  /usr/local/www/webfinger;

    if ($account_name) {
      rewrite ^(.*)$ /$account_name.json break;
    }

    try_files $uri = 404;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;JSON File for WebFinger&lt;/h3&gt;
&lt;p&gt;At this point, I created the &lt;code&gt;/usr/local/www/webfinger&lt;/code&gt; directory and added a file named &lt;code&gt;stefano.json&lt;/code&gt; (matching &lt;code&gt;$account_name&lt;/code&gt;) with the following content:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-json"&gt;{
  &amp;quot;subject&amp;quot;: &amp;quot;acct:stefano@dragas.it&amp;quot;,
  &amp;quot;aliases&amp;quot;: [
    &amp;quot;https://mastodon.bsd.cafe/@stefano&amp;quot;,
    &amp;quot;https://mastodon.bsd.cafe/users/stefano&amp;quot;
  ],
  &amp;quot;links&amp;quot;: [
    {
      &amp;quot;rel&amp;quot;: &amp;quot;http://webfinger.net/rel/profile-page&amp;quot;,
      &amp;quot;type&amp;quot;: &amp;quot;text/html&amp;quot;,
      &amp;quot;href&amp;quot;: &amp;quot;https://mastodon.bsd.cafe/@stefano&amp;quot;
    },
    {
      &amp;quot;rel&amp;quot;: &amp;quot;self&amp;quot;,
      &amp;quot;type&amp;quot;: &amp;quot;application/activity+json&amp;quot;,
      &amp;quot;href&amp;quot;: &amp;quot;https://mastodon.bsd.cafe/users/stefano&amp;quot;
    },
    {
      &amp;quot;rel&amp;quot;: &amp;quot;http://ostatus.org/schema/1.0/subscribe&amp;quot;,
      &amp;quot;template&amp;quot;: &amp;quot;https://mastodon.bsd.cafe/authorize_interaction?uri={uri}&amp;quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In practice, this will return a reference to my main Mastodon account.&lt;/p&gt;
&lt;h3&gt;Testing the Setup&lt;/h3&gt;
&lt;p&gt;After reloading the nginx configurations, it will be enough to test it:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;# curl &amp;quot;https://dragas.it/.well-known/webfinger?resource=acct:stefano@dragas.it&amp;quot;

{
  &amp;quot;subject&amp;quot;: &amp;quot;acct:stefano@dragas.it&amp;quot;,
  &amp;quot;aliases&amp;quot;: [
    &amp;quot;https://mastodon.bsd.cafe/@stefano&amp;quot;,
    &amp;quot;https://mastodon.bsd.cafe/users/stefano&amp;quot;
  ],
  &amp;quot;links&amp;quot;: [
    {
      &amp;quot;rel&amp;quot;: &amp;quot;http://webfinger.net/rel/profile-page&amp;quot;,
      &amp;quot;type&amp;quot;: &amp;quot;text/html&amp;quot;,
      &amp;quot;href&amp;quot;: &amp;quot;https://mastodon.bsd.cafe/@stefano&amp;quot;
    },
    {
      &amp;quot;rel&amp;quot;: &amp;quot;self&amp;quot;,
      &amp;quot;type&amp;quot;: &amp;quot;application/activity+json&amp;quot;,
      &amp;quot;href&amp;quot;: &amp;quot;https://mastodon.bsd.cafe/users/stefano&amp;quot;
    },
    {
      &amp;quot;rel&amp;quot;: &amp;quot;http://ostatus.org/schema/1.0/subscribe&amp;quot;,
      &amp;quot;template&amp;quot;: &amp;quot;https://mastodon.bsd.cafe/authorize_interaction?uri={uri}&amp;quot;
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In this case, everything works perfectly. When you search for my email address from any fediverse instance, my &lt;a href="https://bsd.cafe"&gt;BSD Cafe&lt;/a&gt; profile will appear.&lt;/p&gt;
&lt;h3&gt;Conclusion&lt;/h3&gt;
&lt;p&gt;This approach allows you to define multiple users for each domain. You just need to add the corresponding mapping in the nginx configuration and the &lt;code&gt;.json&lt;/code&gt; file with all the data.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Tue, 08 Oct 2024 12:53:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2024/10/08/using-a-permanent-webfinger-address/</guid><category>fediverse</category><category>ownyourdata</category><category>mastodon</category><category>snac2</category><category>snac</category><category>mitra</category><category>gotosocial</category><category>akkoma</category><category>hosting</category><category>social</category><category>web</category></item><item><title>Moving an entire FreeBSD installation to a new host or VM in a few easy steps</title><link>https://it-notes.dragas.net/2024/09/16/moving-freebsd-installation-new-host-vm/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/hard_disk.webp" alt="Moving an entire FreeBSD installation to a new host or VM in a few easy steps"&gt;&lt;/p&gt;&lt;p&gt;FreeBSD, especially when installed on ZFS, is incredibly simple to manage, &lt;a href="https://it-notes.dragas.net/2022/05/30/how-we-are-migrating-many-of-our-servers-from-linux-to-freebsd-part-2/"&gt;back up&lt;/a&gt;, and move to a different system.&lt;/p&gt;
&lt;p&gt;I often find myself having to move an entire system from one host to another, from a physical host to a VM, or vice versa, from a VM to a physical host.&lt;/p&gt;
&lt;p&gt;By following the approach of keeping the operating system clean and running all services within jails, this operation is usually quite simple: install the operating system on the new host, perform the few necessary configurations (like pf and network interfaces), transfer the jail datasets, restart the jails on the new host, and update the DNS.&lt;/p&gt;
&lt;p&gt;However, sometimes I need to move the entire host, including the original operating system. This article will describe this operation when the FreeBSD system uses ZFS as its root file system. This process isn't much more difficult, but there are a few details to keep in mind. Below is a breakdown of the two bootloader scenarios:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;UEFI Bootloader&lt;/li&gt;
&lt;li&gt;BIOS Bootloader (non-UEFI)&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;UEFI Bootloader&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Start from an ISO or image of &lt;a href="https://mfsbsd.vx.sk/"&gt;mfsbsd&lt;/a&gt;. Partition the disk according to the layout of the source system. If the source system is a standard FreeBSD (ZFS) installation, partition the destination disk (assumed to be da0) as follows. &lt;strong&gt;WARNING&lt;/strong&gt;: The first command will destroy any existing partition table on the disk.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;gpart destroy -F /dev/da0
gpart create -s gpt da0
gpart add -t efi -s 200M -l efiboot0 da0
newfs_msdos -F 32 -c 1 /dev/da0p1
mount -t msdosfs /dev/da0p1 /mnt
mkdir -p /mnt/EFI/BOOT
cp /boot/loader.efi /mnt/EFI/BOOT/BOOTX64.efi
umount /mnt
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Create additional partitions for swap and ZFS:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;gpart add -a 1m -t freebsd-swap -s 2g -l swap0 da0
gpart add -a 1m -t freebsd-zfs -l zfs0 da0
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Create the ZFS pool:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;zpool create -O compression=zstd -O atime=off zroot /dev/gpt/zfs0
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;On the source system, create a snapshot of all datasets:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;zfs snapshot -r zroot@snap01
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; You will need to connect to the source system using a user with privileges over the entire filesystem. For simplicity, I'll use &lt;code&gt;root&lt;/code&gt;, but it's not recommended to leave &lt;code&gt;root&lt;/code&gt; accessible via SSH. Limit access to private keys only. To allow root login over SSH, edit &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; and set &lt;code&gt;PermitRootLogin yes&lt;/code&gt;. After moving the server, &lt;strong&gt;remember to disable root access&lt;/strong&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;If &lt;a href="https://www.maier-komor.de/mbuffer.html"&gt;mbuffer&lt;/a&gt; is installed on the source system, it can help improve the performance of the transfer. If not, remove it from the next pipe command, which should be run from the destination system. The &lt;em&gt;-s&lt;/em&gt; will set the block-size, the &lt;em&gt;-m&lt;/em&gt; will set the buffer size (128MB, here):&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;ssh root@sourceip &amp;quot;zfs send -RLv zroot@snap01 | mbuffer -s 128k -m 128M&amp;quot; | zfs receive -F zroot
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;You have two options now:&lt;/li&gt;
&lt;li&gt;If the source server was idle and no further synchronization is required, proceed to the next step.&lt;/li&gt;
&lt;li&gt;If the transfer took a long time and services on the source server need to be stopped (e.g., databases), stop what you can, take another snapshot (e.g., &lt;code&gt;zfs snapshot -r zroot@snap02&lt;/code&gt;), and synchronize the differences by running another incremental send-receive from the destination server:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;ssh root@sourceip &amp;quot;zfs send -RLv -i zroot@snap01 zroot@snap02 | mbuffer -s 128k -m 128M&amp;quot; | zfs receive -F zroot
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Set the boot filesystem:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;zpool set bootfs=zroot/ROOT/default zroot
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;BIOS Bootloader&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Start from an ISO or image of &lt;a href="https://mfsbsd.vx.sk/"&gt;mfsbsd&lt;/a&gt;. Partition the disk according to the layout of the source system. If the source system is a standard FreeBSD (ZFS) installation, partition the destination disk (assumed to be &lt;code&gt;da0&lt;/code&gt;) as follows. &lt;strong&gt;WARNING:&lt;/strong&gt; The first command will destroy any existing partition table on the disk.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;gpart destroy -F /dev/da0
gpart create -s gpt da0
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Create the necessary partitions:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;gpart add -a 1m -t freebsd-boot -s 512k -l boot da0
gpart add -a 1m -t freebsd-swap -s 2g -l swap0 da0
gpart add -a 1m -t freebsd-zfs -l zfs0 da0
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Create the ZFS pool:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;zpool create -O compression=zstd -O atime=off zroot /dev/gpt/zfs0
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;On the source system, create a snapshot of all datasets:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;zfs snapshot -r zroot@snap01
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; You will need to connect to the source system using a user with privileges over the entire filesystem. For simplicity, I'll use &lt;code&gt;root&lt;/code&gt;, but it's not recommended to leave &lt;code&gt;root&lt;/code&gt; accessible via SSH. Limit access to private keys only. To allow root login over SSH, edit &lt;code&gt;/etc/ssh/sshd_config&lt;/code&gt; and set &lt;code&gt;PermitRootLogin yes&lt;/code&gt;.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;If &lt;code&gt;mbuffer&lt;/code&gt; is installed on the source system, it can help improve the performance of the transfer. If not, remove it from the next pipe command, which should be run from the destination system:&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;ssh root@sourceip &amp;quot;zfs send -RLv zroot@snap01 | mbuffer -s 128k -m 128M&amp;quot; | zfs receive -F zroot
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;You have two options now:&lt;/li&gt;
&lt;li&gt;If the source server was idle and no further synchronization is required, proceed to the next step.&lt;/li&gt;
&lt;li&gt;If the transfer took a long time and services on the source server need to be stopped (e.g., databases), stop what you can, take another snapshot (e.g., &lt;code&gt;zfs snapshot -r zroot@snap02&lt;/code&gt;), and synchronize the differences by running another incremental send-receive from the destination server:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;ssh root@sourceip &amp;quot;zfs send -RLv -i zroot@snap01 zroot@snap02 | mbuffer -s 128k -m 128M&amp;quot; | zfs receive -F zroot
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Install the bootloader on the destination server’s disk:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;gpart bootcode -b /boot/pmbr -p /boot/gptzfsboot -i 1 da0
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Set the boot filesystem:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;zpool set bootfs=zroot/ROOT/default zroot
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;At this point, everything should be ready for boot. Reboot and verify that the various mountpoints (e.g., swap, etc.) and network interface settings are correct. If everything works fine, the server will be an exact copy of the original.&lt;/p&gt;
&lt;p&gt;Moving or duplicating an entire FreeBSD system is simple, fast, and reliable. The ability to use ZFS incremental replication is an excellent method for minimizing downtime, especially for large systems. The initial copy will happen "live" while the source system continues to run without issues. Subsequent incrementals will be much faster, making the switch-over process seamless and efficient.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Mon, 16 Sep 2024 09:41:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2024/09/16/moving-freebsd-installation-new-host-vm/</guid><category>freebsd</category><category>server</category><category>zfs</category><category>tutorial</category><category>hosting</category><category>ownyourdata</category><category>series</category><category>tipsandtricks</category></item><item><title>A Small Compendium of Fediverse Platforms I Use</title><link>https://it-notes.dragas.net/2024/09/12/a-small-compendium-of-fediverse-platforms-i-use/</link><description>&lt;p&gt;&lt;img src="https://unsplash.com/photos/9Xf-jxvfpW8/download?ixid=M3wxMjA3fDB8MXxhbGx8MXx8fHx8fHx8MTc0MTU5MTUzNXw&amp;force=true&amp;w=1920" alt="Photo by &amp;lt;a href=&amp;quot;https://unsplash.com/it/@elenarossini&amp;quot;&amp;gt;Elena Rossini&amp;lt;/a&amp;gt; on Unsplash"&gt;&lt;/p&gt;&lt;p&gt;Following my &lt;a href="https://it-notes.dragas.net/2023/01/15/deploying-a-piece-of-the-fediverse/"&gt;old article about the Fediverse software I've experimented and use&lt;/a&gt;, I've decided to convert a Mastodon post into this small blog post.&lt;/p&gt;
&lt;p&gt;In the past few days, I revisited several of my old Fediverse instances after some friends asked me to help them set up a new one. While I was at it, I took the opportunity to perform maintenance on some leftover instances I still manage. Here’s a summary of my experience with various platforms:&lt;/p&gt;
&lt;h2&gt;&lt;a href="https://akkoma.social/"&gt;Akkoma&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;This is my oldest instance, still running since 2022. It was offline for about 3 or 4 months, but I recently updated it to the latest version and restarted it. After upgrading the software and the database, it didn't show any problem. Akkoma is a very good solution, supports quote posts and emoji reactions.&lt;/p&gt;
&lt;h2&gt;&lt;a href="https://gotosocial.org/"&gt;GoToSocial&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I helped a friend update their GoToSocial instance. While the software itself was up-to-date, the underlying system needed an update. I noticed that when the number of followings exceeds 2000, the instance becomes a bit sluggish. PostgreSQL isn’t the issue in this case – it's the GoToSocial process itself that seems to get heavy on the VPS. Despite this, GoToSocial remains very usable, and I see a lot of potential in it. The Mastodon API is well-implemented, and it works seamlessly with major apps.&lt;/p&gt;
&lt;h2&gt;&lt;a href="https://codeberg.org/silverpill/mitra"&gt;Mitra&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;Mitra is another Fediverse platform I’ve been exploring. I helped someone with around 1000 followers and followings migrate from a large Mastodon instance to Mitra. There were no speed issues, though sending messages does make the server slightly "heavier" for a short time. The Mastodon API is partially implemented, but the software is evolving quickly. I find its native interface quite user-friendly, and it’s a platform worth keeping an eye on.&lt;/p&gt;
&lt;h2&gt;&lt;a href="https://codeberg.org/grunfink/snac2"&gt;Snac2&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;I’ve always had a soft spot for Snac2. It doesn’t use a database, and its design choices make it ideal for small instances. One feature I particularly like is how it sends posts to all known instances, which increases visibility and interaction. The interface is basic, with no JavaScript, which is a nice change, but it might feel too minimalistic for users coming from Mastodon. However, the Mastodon API support is steadily improving with each release. Snac2 does struggle with larger numbers, but this is more due to the underlying file system than the software itself. Snac2 now supports moving in/out, so it's easy top test it. I highly recommend it for anyone looking to self-host a small or single-user instance. &lt;/p&gt;
&lt;h2&gt;&lt;a href="https://github.com/mastodon/mastodon"&gt;Mastodon&lt;/a&gt;&lt;/h2&gt;
&lt;p&gt;My old personal instance of Mastodon was stuck on version 4.1.x and had been offline for a few months. I updated the FreeBSD Jail, upgraded Mastodon to 4.2.12, and then to 4.3.0 without any issues. I also helped a friend migrate their Pleroma-based instance to Mastodon. This user has about 5000 followers and followings, and the instance runs on FreeBSD on an arm64 VPS for around 3 euros per month. Aside from media storage (which isn’t Mastodon’s fault), there were no significant issues. Although Mastodon is sometimes criticized for being resource-intensive, its modular design ensures that even during high load, queues might slow down, but the local timeline and navigation remain reasonably fast. This makes it a strong contender for larger-scale use.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Overall, I feel that these platforms are evolving in the right direction. The developers are doing a fantastic job, and the Fediverse is growing stronger with each new release. Well done to all the devs working hard behind the scenes!&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Thu, 12 Sep 2024 18:45:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2024/09/12/a-small-compendium-of-fediverse-platforms-i-use/</guid><category>fediverse</category><category>akkoma</category><category>gotosocial</category><category>mitra</category><category>snac2</category><category>snac</category><category>mastodon</category><category>freebsd</category><category>hosting</category><category>server</category><category>social</category><category>web</category><category>ownyourdata</category></item><item><title>Make your own Read-Only Device with NetBSD</title><link>https://it-notes.dragas.net/2024/09/10/make-your-own-readonly-device-with-netbsd/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/embedded2.webp" alt="Make your own Read-Only Device with NetBSD"&gt;&lt;/p&gt;&lt;p&gt;One detail that is often overlooked when dealing with embedded (or remote) devices is a key point of vulnerability: the file system.&lt;/p&gt;
&lt;p&gt;For non-COW file systems (like ext4 on Linux, FFS, etc.), there are situations where a crash or a power outage could cause corruption, requiring manual intervention. This risk is especially present when using root on SD cards or similar storage media in embedded systems. Over time, these media are destined to fail due to numerous writes.&lt;/p&gt;
&lt;p&gt;For certain use cases, it's advisable to set up a read-only root file system, which ensures better reliability in case of system issues. Think of scenarios like a router (critical for network access) or a caching reverse-proxy, such as the one described in my series &lt;a href="https://it-notes.dragas.net/2024/09/03/make-your-own-cdn-netbsd/"&gt;"Make your own CDN"&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;While &lt;a href="https://it-notes.dragas.net/2024/05/31/freebsd-tips-and-tricks-native-ro-rootfs/"&gt;FreeBSD natively supports this configuration&lt;/a&gt; and some Linux distributions offer custom solutions (e.g., &lt;a href="https://wiki.alpinelinux.org/wiki/Alpine_local_backup"&gt;Alpine Linux&lt;/a&gt;), &lt;strong&gt;NetBSD&lt;/strong&gt; stands out as an excellent choice for such devices. It supports nearly all embedded devices, is lightweight, and its stability minimizes the need for frequent updates.&lt;/p&gt;
&lt;h3&gt;The Key Idea&lt;/h3&gt;
&lt;p&gt;Although NetBSD doesn't provide native read-only support, it's flexible enough to allow for this configuration. Many years ago, I followed &lt;a href="https://www.netbsd.org/~jschauma/netbsd-solidstate.html"&gt;a howto&lt;/a&gt; to set up a read-only system, and the idea remains simple and effective: &lt;strong&gt;NetBSD writes primarily to specific locations&lt;/strong&gt;, unlike some Linux distributions that attempt to write to various parts of the file system. On NetBSD, the main write targets are the &lt;code&gt;/tmp&lt;/code&gt; directory and &lt;code&gt;/var&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;With this in mind, we can configure these directories to reside in memory file systems (mfs) and ensure that &lt;code&gt;/var&lt;/code&gt; contains everything necessary for the system to function correctly.&lt;/p&gt;
&lt;h3&gt;1. Prepare the Environment&lt;/h3&gt;
&lt;p&gt;Start with a basic NetBSD installation (I'll skip the installation steps as there are many tutorials available, and it's straightforward).&lt;/p&gt;
&lt;p&gt;As a first step, clean up the &lt;code&gt;/var&lt;/code&gt; directory. After each reboot, its contents will be extracted into the mfs and occupy RAM.&lt;/p&gt;
&lt;h4&gt;Disable &lt;code&gt;man.db&lt;/code&gt; generation and swap usage:&lt;/h4&gt;
&lt;p&gt;Edit the following configuration files:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;# /etc/rc.conf
makemandb=NO
no_swap=YES
&lt;/code&gt;&lt;/pre&gt;

&lt;pre class="highlight"&gt;&lt;code&gt;# /etc/daily.conf
run_makemandb=NO
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then, remove the current &lt;code&gt;man.db&lt;/code&gt; file:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;rm /var/db/man.db
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Once this is done, create a compressed archive of the current &lt;code&gt;/var&lt;/code&gt; contents to be replicated at every boot:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;cd /
tar -cvzf var-image.tar.gz var
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;2. Create a Custom Startup Script&lt;/h3&gt;
&lt;p&gt;Next, create a file called &lt;code&gt;/etc/rc.d/mount_mfs_fs&lt;/code&gt; with the following content:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;#!/bin/sh
#
# mount_mfs_fs: mount memory file system for /var
# by roby, 23 jun 2003 - adapted by Stefano - 01 Sep 2024

# PROVIDE: mount_mfs_fs
# REQUIRE: root

. /etc/rc.subr

name=&amp;quot;mount_mfs_fs&amp;quot;
start_cmd=&amp;quot;mount_mfs_fs_start&amp;quot;
stop_cmd=&amp;quot;:&amp;quot;

mount_mfs_fs_start()
{
    # Check if the /var entry is present and uncommented in /etc/fstab
    if grep -q '^tmpfs[[:space:]]\+/var[[:space:]]\+tmpfs[[:space:]]\+rw,-m1777,-sram%25' /etc/fstab; then
        echo &amp;quot;Mounting memory file system: /var&amp;quot;

        # Mount the file system for /var
        mount /var

        # Extract the contents of the tar file into /var
        tar -xvzpf /var-image.tar.gz -C /

        echo &amp;quot;Mounting memory file systems: Done.&amp;quot;
    else
        echo &amp;quot;The tmpfs entry for /var is not present or is commented out in /etc/fstab. Skipping mount and extraction.&amp;quot;
    fi
    sleep 5
}

load_rc_config $name
run_rc_command &amp;quot;$1&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Make the script executable:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;chmod a+rx /etc/rc.d/mount_mfs_fs
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;3. Modify Boot Process&lt;/h3&gt;
&lt;p&gt;Now, modify the &lt;code&gt;/etc/rc.d/mountcritlocal&lt;/code&gt; file to require this script to run before its execution:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;#!/bin/sh
#
# $NetBSD: mountcritlocal,v 1.17 2022/02/20 14:42:07 alnsn Exp $
#

# PROVIDE: mountcritlocal
# REQUIRE: mount_mfs_fs

$_rc_subr_loaded . /etc/rc.subr

name=&amp;quot;mountcritlocal&amp;quot;
start_cmd=&amp;quot;mountcritlocal_start&amp;quot;
stop_cmd=&amp;quot;:&amp;quot;

mountcritlocal_start()
{
    #       Mount critical file systems that are `local'
    #       (as specified in $critical_filesystems_local)
    #       This usually includes /var.
    #
    mount_critical_filesystems local || return $?
    if checkyesno zfs; then
        mount_critical_filesystems_zfs || return $?
    fi
    return 0
}

load_rc_config $name
load_rc_config_var zfs zfs
run_rc_command &amp;quot;$1&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;4. Configure &lt;code&gt;/etc/fstab&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Edit the &lt;code&gt;/etc/fstab&lt;/code&gt; file to mount &lt;code&gt;/tmp&lt;/code&gt; and &lt;code&gt;/var&lt;/code&gt; in memory:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;tmpfs           /tmp        tmpfs   rw,-m1777,-sram%25
tmpfs           /var        tmpfs   rw,-m1777,-sram%25
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;5. Test the Configuration&lt;/h3&gt;
&lt;p&gt;You can now reboot the system and check if everything works correctly. Once logged in, the &lt;code&gt;df&lt;/code&gt; command should show something like this:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;Filesystem      1K-blocks         Used        Avail %Cap Mounted on
/dev/ld0a        18298254       393938     16989404   2% /
tmpfs              524192         4224       519968   0% /var
kernfs                  1            1            0 100% /kern
ptyfs                   1            1            0 100% /dev/pts
procfs                  4            4            0 100% /proc
tmpfs              524192            4       524188   0% /var/shm
tmpfs              524192            4       524188   0% /tmp
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;6. Set Root to Read-Only&lt;/h3&gt;
&lt;p&gt;Currently, the system is still running in read-write mode, but &lt;code&gt;/var&lt;/code&gt; and &lt;code&gt;/tmp&lt;/code&gt; are in memory disks. To switch the root file system to read-only, edit the &lt;code&gt;/etc/fstab&lt;/code&gt; file like this:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;/dev/ld0a               /       ffs     ro               1 1
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;On the next reboot, the system will be in pure read-only mode.&lt;/p&gt;
&lt;h3&gt;7. Perform Updates&lt;/h3&gt;
&lt;p&gt;To install packages, update the system, or make any changes, you'll need to temporarily switch back to read-write mode. Comment out &lt;code&gt;/var&lt;/code&gt; in mfs, change the root file system back to read-write, and reboot:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;mount -uw /
vi /etc/fstab
/dev/ld0a               /       ffs     rw               1 1
[...]
#tmpfs           /var    tmpfs   rw,-m1777,-sram%25
[...]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;After making the necessary changes, regenerate the &lt;code&gt;/var&lt;/code&gt; tarball as done in step 1.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Tue, 10 Sep 2024 01:41:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2024/09/10/make-your-own-readonly-device-with-netbsd/</guid><category>netbsd</category><category>server</category><category>hosting</category><category>tutorial</category><category>ownyourdata</category></item><item><title>Make Your Own CDN with NetBSD</title><link>https://it-notes.dragas.net/2024/09/03/make-your-own-cdn-netbsd/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/embedded.jpg" alt="Make Your Own CDN with NetBSD"&gt;&lt;/p&gt;&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;This article is a spin-off from &lt;a href="https://it-notes.dragas.net/2024/08/29/make-your-own-cdn-openbsd/"&gt;a previous post on how to create a self-hosted CDN&lt;/a&gt;, based on OpenBSD, but this time we'll focus on using &lt;a href="https://www.netbsd.org/"&gt;NetBSD&lt;/a&gt;. The idea is to &lt;a href="https://it-notes.dragas.net/2024/08/26/building-a-self-hosted-cdn-for-bsd-cafe-media/"&gt;create reverse proxies with local caching&lt;/a&gt;. These proxies would cache the content on the first request and serve it directly afterward. The proxies would be distributed across different regions, and the DNS would route requests to the nearest proxy based on the caller’s location. All this is achieved without relying on external CDNs, using self-managed tools instead. &lt;/p&gt;
&lt;p&gt;NetBSD is a lightweight, stable, and secure operating system that supports a wide range of hardware, making it an excellent choice for a caching reverse proxy. Devices that other operating systems may soon abandon, such as early Raspberry Pi models or i386 architecture, are still fully supported by NetBSD and will continue to be so. Additionally, NetBSD is an outstanding platform for virtualization (using &lt;a href="https://wiki.netbsd.org/ports/xen/howto/"&gt;Xen&lt;/a&gt; or &lt;a href="https://www.netbsd.org/docs/guide/en/chap-virt.html"&gt;qemu/nvmm&lt;/a&gt;) and deserves more attention than it currently receives.&lt;/p&gt;
&lt;p&gt;The choice of Varnish is based on several factors, with the main ones being the ability to keep the cache in RAM (which means it can run on read-only systems) and the ability to flush the cache remotely. For example, with each change to my blog, I can choose whether to perform an immediate flush (such as for a new article or an error) or wait for the cache's "natural" expiration (such as for a typo or minor, non-critical changes).&lt;/p&gt;
&lt;p&gt;While I won't detail the installation process for NetBSD, as it depends heavily on the hardware you have available, I will guide you through setting up a self-hosted CDN using NetBSD, Varnish, nginx, and the acme.sh or lego tool for SSL certificate management.&lt;/p&gt;
&lt;h2&gt;Installation&lt;/h2&gt;
&lt;p&gt;During the installation of NetBSD, ensure that you enable support for binary package management. This will install &lt;code&gt;pkgin&lt;/code&gt;, &lt;a href="https://pkgin.net/"&gt;a tool that simplifies package management&lt;/a&gt; on NetBSD. If you skip this step during installation, you can still install pkgin later, but it's easier to let the installer handle it.&lt;/p&gt;
&lt;p&gt;Once your system is up and running, use pkgin to install the necessary packages: Varnish, nginx, and, depending on your preference, either acme.sh or Go (if you plan to compile lego). Although lego is not available as a precompiled package, you can easily compile it locally using Go, but for simplicity, I recommend using acme.sh.&lt;/p&gt;
&lt;p&gt;For this setup, I will present two methods for generating and renewing certificates: using acme.sh or compiling lego - to reach the same final outcome as the &lt;a href="https://it-notes.dragas.net/2024/08/29/make-your-own-cdn-openbsd/"&gt;OpenBSD article&lt;/a&gt;.&lt;/p&gt;
&lt;h3&gt;Option 1: Using acme.sh (Recommended)&lt;/h3&gt;
&lt;p&gt;&lt;a href="https://github.com/acmesh-official/acme.sh"&gt;acme.sh&lt;/a&gt; is a simple, yet powerful, shell script that handles certificate generation and renewal with ease. To install acme.sh:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;pkgin in acmesh varnish nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Once acme.sh is installed, you can proceed to configure it for certificate management. It supports DNS authentication and integrates with many DNS providers, making it a flexible choice.&lt;/p&gt;
&lt;h3&gt;Option 2: Compiling Lego&lt;/h3&gt;
&lt;p&gt;If you prefer to use lego, you will need to compile it manually, as it is not available as a precompiled package for NetBSD. First, install Go and other necessary packages:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;pkgin in go varnish nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;To compile lego, you’ll need some disk space. Since the &lt;code&gt;/tmp&lt;/code&gt; directory in NetBSD is often mounted as &lt;code&gt;tmpfs&lt;/code&gt; (using RAM), you may run out of space during compilation if your system has limited memory. You can temporarily disable &lt;code&gt;tmpfs&lt;/code&gt; by editing &lt;code&gt;/etc/fstab&lt;/code&gt; and commenting out the relevant line:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;#tmpfs           /tmp    tmpfs   rw,-m=1777,-s=ram%25
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;After rebooting, compile lego:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;export GO111MODULE=on
go122 install github.com/go-acme/lego/v4/cmd/lego@latest

cp go/bin/lego /usr/pkg/bin/
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Once lego is compiled and installed, you can uncomment the &lt;code&gt;/tmp&lt;/code&gt; line in &lt;code&gt;/etc/fstab&lt;/code&gt; and reboot again.&lt;/p&gt;
&lt;h2&gt;Configuring Varnish and nginx&lt;/h2&gt;
&lt;p&gt;First, copy the necessary &lt;code&gt;rc.d&lt;/code&gt; scripts for nginx and Varnish:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;cp /usr/pkg/share/examples/rc.d/nginx /etc/rc.d/
cp /usr/pkg/share/examples/rc.d/varnishd /etc/rc.d/
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then, add the following to &lt;code&gt;/etc/rc.conf&lt;/code&gt; to enable and configure nginx and Varnish:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;nginx=YES
varnishd=YES
varnishd_flags=&amp;quot;-f /usr/pkg/etc/varnish/default.vcl -T localhost:9999 -a &amp;quot;/var/run/varnish.sock&amp;quot;,user=nginx,group=varnish,mode=660 -s default,500m&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In this configuration, Varnish listens on a Unix socket, and nginx connects to it. This approach is more efficient and helps avoid some issues that may arise when exposing Varnish over an IP/port.&lt;/p&gt;
&lt;h3&gt;Creating the Varnish VCL Configuration&lt;/h3&gt;
&lt;p&gt;Next, create the VCL configuration file for Varnish at &lt;code&gt;/usr/pkg/etc/varnish/default.vcl&lt;/code&gt;:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;vcl 4.1;
import std;

# Backend - it-notes.dragas.net
backend it_notes {
    .host = &amp;quot;myBackendIP&amp;quot;;
    .port = &amp;quot;80&amp;quot;;
}

# ACL - purge - it-notes.dragas.net
acl purge_it_notes {
    &amp;quot;allowedToPurge_IP&amp;quot;;
}

sub vcl_recv {
    # it-notes.dragas.net
    if (req.http.Host == &amp;quot;it-notes.dragas.net&amp;quot;) {
        set req.backend_hint = it_notes;
        set req.http.Host = &amp;quot;it-notes.dragas.net&amp;quot;;

        # PURGE - it-notes.dragas.net
        if (req.method == &amp;quot;PURGE&amp;quot;) {
            std.log(&amp;quot;Purge request received for &amp;quot; + req.url);

            if (!std.ip(req.http.X-Forwarded-For, &amp;quot;0.0.0.0&amp;quot;) ~ purge_it_notes) {
                return (synth(405, &amp;quot;Not allowed.&amp;quot;));
            }

            if (req.url == &amp;quot;/&amp;quot; || req.url == &amp;quot;/*&amp;quot;) {
                ban(&amp;quot;req.http.host == &amp;quot; + req.http.host);
                return(synth(200, &amp;quot;Entire cache has been cleared.&amp;quot;));
            }
            return (purge);
        }

    } else {
        # Other domains - 404
        return (synth(404, &amp;quot;Domain not found&amp;quot;));
    }

    if (req.method != &amp;quot;GET&amp;quot; &amp;amp;&amp;amp; req.method != &amp;quot;HEAD&amp;quot;) {
        return (pipe);
    }

    return (hash);
}

sub vcl_backend_response {
    # TTL - it-notes.dragas.net
    if (bereq.http.host == &amp;quot;it-notes.dragas.net&amp;quot;) {
        if (bereq.url ~ &amp;quot;\.(gif|jpg|jpeg|png|webp|ico|css|js)$&amp;quot;) {
            set beresp.ttl = 1w;
            set beresp.grace = 1d;
            set beresp.keep = 7d;
            unset beresp.http.Set-Cookie;
            unset beresp.http.Cache-Control;
            set beresp.http.Cache-Control = &amp;quot;public, max-age=604800&amp;quot;;
        } else {
            set beresp.ttl = 15m;
            set beresp.grace = 48h;
            set beresp.keep = 7d;
        }
    }

    # Remove some headers
    unset beresp.http.Server;
    unset beresp.http.X-Powered-By;
    unset beresp.http.Via;

    return (deliver);
}

sub vcl_deliver {
    # Add X-Cache header
    if (obj.hits &amp;gt; 0) {
        set resp.http.X-Cache = &amp;quot;HIT&amp;quot;;
    } else {
        set resp.http.X-Cache = &amp;quot;MISS&amp;quot;;
    }

    std.log(&amp;quot;Delivering content for &amp;quot; + req.url + &amp;quot; - Cache: &amp;quot; + resp.http.X-Cache);

    # Remove Varnish headers
    unset resp.http.Via;
    unset resp.http.X-Varnish;

    return (deliver);
}

sub vcl_hash {
    hash_data(req.url);
    if (req.http.host) {
        hash_data(req.http.host);
    } else {
        hash_data(server.ip);
    }
    return (lookup);
}

sub vcl_hit {
    return (deliver);
}

sub vcl_miss {
    return (fetch);
}

sub vcl_purge {
    std.log(&amp;quot;Purge executed for &amp;quot; + req.url);
    return (synth(200, &amp;quot;Purge successful&amp;quot;));
}

sub vcl_synth {
    set resp.http.Content-Type = &amp;quot;text/html; charset=utf-8&amp;quot;;
    set resp.http.Retry-After = &amp;quot;5&amp;quot;;
    synthetic({&amp;quot;&amp;lt;!DOCTYPE html&amp;gt;
        &amp;lt;html&amp;gt;
            &amp;lt;head&amp;gt;
                &amp;lt;title&amp;gt;&amp;quot;} + resp.status + &amp;quot; &amp;quot; + resp.reason + {&amp;quot;&amp;lt;/title&amp;gt;
            &amp;lt;/head&amp;gt;
            &amp;lt;body&amp;gt;
                &amp;lt;h1&amp;gt;Status &amp;quot;} + resp.status + &amp;quot; &amp;quot; + resp.reason + {&amp;quot;&amp;lt;/h1&amp;gt;
                &amp;lt;p&amp;gt;&amp;quot;} + resp.reason + {&amp;quot;&amp;lt;/p&amp;gt;
                &amp;lt;h3&amp;gt;Guru Meditation:&amp;lt;/h3&amp;gt;
                &amp;lt;p&amp;gt;XID: &amp;quot;} + req.xid + {&amp;quot;&amp;lt;/p&amp;gt;
                &amp;lt;hr&amp;gt;
                &amp;lt;p&amp;gt;Varnish cache server&amp;lt;/p&amp;gt;
            &amp;lt;/body&amp;gt;
        &amp;lt;/html&amp;gt;
    &amp;quot;});
    return (deliver);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Configuring nginx&lt;/h3&gt;
&lt;p&gt;Now, modify the nginx configuration file at &lt;code&gt;/usr/pkg/etc/nginx/nginx.conf&lt;/code&gt;. Set the number of worker processes to "auto" to take advantage of all server cores, and configure the reverse proxy for your site(s). Here's an example configuration:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;[...]
worker_processes  auto;
[...]

server {
    server_name it-notes.dragas.net;

    location / {
        proxy_method $request_method;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        proxy_pass http://unix:/var/run/varnish.sock;
    }

    access_log /var/log/nginx/access.it-notes.dragas.net.log;
    error_log /var/log/nginx/error.it-notes.dragas.net.log;

    listen [::]:443 ssl;
    listen 443 ssl;
    http2 on;
    # If you're using acme.sh, just change the location of the certificates
    ssl_certificate /root/.lego/certificates/it-notes.dragas.net.crt;
    ssl_certificate_key /root/.lego/certificates/it-notes.dragas.net.key;
}

server {
    if ($host = it-notes.dragas.net) {
        return 301 https://$host$request_uri;
    }
    server_name it-notes.dragas.net;
    listen 80;
    listen [::]:80;
    return 404;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;h3&gt;Starting Varnish and nginx&lt;/h3&gt;
&lt;p&gt;Finally, start the Varnish and nginx services:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;service varnishd start
service nginx start
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If everything is configured correctly, both Varnish and nginx will be up and running, ready to handle incoming connections.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Congratulations, you have successfully set up your own CDN on NetBSD. This solution is lightweight, stable, and fully under your control, allowing you to break free from the constraints of major service providers. With NetBSD's broad hardware support and minimal overhead, this setup can run on a wide variety of devices, making it a versatile choice for self-hosted solutions.&lt;/p&gt;
&lt;p&gt;I'm using it as a test and as a read-only root filesystem with a RAM-only local cache for my blog on a &lt;a href="https://www.raspberrypi.com/products/raspberry-pi-zero-w/"&gt;Raspberry Pi Zero W (first edition)&lt;/a&gt;, and as soon as I get the new FTTH, I'll probably make it accessible via IPv6 for Italy, putting it physically into production.&lt;/p&gt;
&lt;p&gt;If your goal is geo-replication, you can use DNS providers that offer location-based routing or set up your own DNS infrastructure to manage and resolve requests according to the user’s location. With multiple reverse proxies, separate DNS servers, and a well-configured cache, you can achieve a highly resilient system with minimal risk of a single point of failure.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Tue, 03 Sep 2024 01:41:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2024/09/03/make-your-own-cdn-netbsd/</guid><category>netbsd</category><category>server</category><category>hosting</category><category>tutorial</category><category>ownyourdata</category><category>vpn</category><category>ha</category><category>wireguard</category><category>web</category><category>cdn</category><category>bsdcafe</category><category>varnish</category><category>series</category></item><item><title>Make Your Own CDN with OpenBSD Base and Just 2 Packages</title><link>https://it-notes.dragas.net/2024/08/29/make-your-own-cdn-openbsd/</link><description>&lt;p&gt;&lt;img src="https://it-notes.dragas.net/featured/web_text.webp" alt="Make Your Own CDN with OpenBSD Base and Just 2 Packages"&gt;&lt;/p&gt;&lt;h2&gt;Introduction&lt;/h2&gt;
&lt;p&gt;This article is a "spin-off" from the previous post "&lt;a href="https://it-notes.dragas.net/2024/08/26/building-a-self-hosted-cdn-for-bsd-cafe-media/"&gt;Building a Self-Hosted CDN for BSD Cafe Media&lt;/a&gt;," which I recommend reading, based on FreeBSD. In that article, I showed how I addressed the issue of geo-replication and geo-distribution of BSD Cafe media content. If you prefer a NetBSD-based setup, &lt;a href="https://it-notes.dragas.net/2024/09/03/make-your-own-cdn-netbsd/"&gt;there's an article that describes how to do it&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The internet today relies TOO MUCH on just a few big players. When one of them stops working, half the world is impacted because too many services, in my opinion, depend on them. “Too big to fail,” some might say. 
“Single Point of Failure,” I respond."&lt;/p&gt;
&lt;p&gt;The strength of the internet has always been its extreme decentralization, which is now less evident due to this phenomenon.&lt;/p&gt;
&lt;p&gt;In this article, I want to show how easy it is to create a self-hosted CDN using OpenBSD and just two external packages: &lt;a href="https://varnish-cache.org/"&gt;Varnish&lt;/a&gt; and &lt;a href="https://github.com/go-acme/lego"&gt;Lego&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Actually, only one package is truly needed (Varnish), and SSL certificates could be generated using the built-in &lt;a href="https://man.openbsd.org/acme-client.1"&gt;acme-client&lt;/a&gt; in OpenBSD. However, this might be limiting since acme-client handles certificate generation via traditional methods (using a file in &lt;code&gt;.well-known&lt;/code&gt;), but when dealing with a CDN and several reverse proxies listening, you don’t have perfect control over which one will receive the certificate generation validation request.&lt;/p&gt;
&lt;p&gt;The choice of Varnish is based on several factors, with the main ones being the ability to keep the cache in RAM (which means it can run on read-only systems) and the ability to flush the cache remotely. For example, with each change to my blog, I can choose whether to perform an immediate flush (such as for a new article or an error) or wait for the cache's "natural" expiration (such as for a typo or minor, non-critical changes).&lt;/p&gt;
&lt;p&gt;For convenience and practicality, I’ll use the excellent Lego tool-a Go application that supports many DNS authentication methods, including PowerDNS.&lt;/p&gt;
&lt;h2&gt;Installation&lt;/h2&gt;
&lt;p&gt;The steps are quite simple. After installing and updating OpenBSD (using the &lt;a href="https://man.openbsd.org/syspatch"&gt;syspatch&lt;/a&gt; command), start by installing the two packages:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-sh"&gt;obcdn# pkg_add lego varnish
quirks-7.14:updatedb-0p0: ok
quirks-7.14 signed on 2024-08-24T13:35:23Z
quirks-7.14: ok
lego-4.16.1: ok
varnish-7.4.2:bzip2-1.0.8p0: ok
varnish-7.4.2:pcre2-10.37p2: ok
useradd: Warning: home directory `/var/varnish' doesn't exist, and -m was not specified
varnish-7.4.2: ok
The following new rcscripts were installed: /etc/rc.d/varnishd
See rcctl(8) for details.
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The next step is to enable Varnish:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;rcctl enable varnishd
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Varnish has a generic startup script, but it's best to customize the startup options. To do this, modify the &lt;code&gt;/etc/rc.conf.local&lt;/code&gt; file:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;varnishd_flags=&amp;quot;-j unix,user=_varnish,ccgroup=_varnish -f /etc/varnish/default.vcl -T localhost:9999 -a localhost:8080 -s default,500m&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This configuration sets Varnish with a 500 MB cache and listens on localhost, port 8080.&lt;/p&gt;
&lt;p&gt;Next, rename the default VCL file to prepare for your own content:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;mv /etc/varnish/default.vcl /etc/varnish/default.vcl.distrib
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Create a new &lt;code&gt;default.vcl&lt;/code&gt; file. Below is an example based on this blog (at the time of writing). You’ll need to adapt it according to your needs, especially if there are cookies or other dynamic content. Note that Varnish will fetch data from a specific backend accessed in http. If privacy is needed, consider creating a VPN between the backend and Varnish, &lt;a href="https://it-notes.dragas.net/2024/08/26/building-a-self-hosted-cdn-for-bsd-cafe-media/"&gt;as briefly mentioned in the previous article&lt;/a&gt;:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-vcl"&gt;vcl 4.1;
import std;

# Backend - it-notes.dragas.net
backend it_notes {
    .host = &amp;quot;myOriginalServer&amp;quot;;
    .port = &amp;quot;80&amp;quot;;
}

# ACL - purge - it-notes.dragas.net
acl purge_it_notes {
    &amp;quot;authorizedIPForCachePurge&amp;quot;;
}

sub vcl_recv {
    # it-notes.dragas.net
    if (req.http.Host == &amp;quot;it-notes.dragas.net&amp;quot;) {
        set req.backend_hint = it_notes;
        set req.http.Host = &amp;quot;it-notes.dragas.net&amp;quot;;

        # PURGE - it-notes.dragas.net
        if (req.method == &amp;quot;PURGE&amp;quot;) {
            std.log(&amp;quot;Purge request received for &amp;quot; + req.url);

            if (!std.ip(req.http.X-Forwarded-For, &amp;quot;0.0.0.0&amp;quot;) ~ purge_it_notes) {
                return (synth(405, &amp;quot;Not allowed.&amp;quot;));
            }

            if (req.url == &amp;quot;/&amp;quot; || req.url == &amp;quot;/*&amp;quot;) {
                ban(&amp;quot;req.http.host == &amp;quot; + req.http.host);
                return(synth(200, &amp;quot;Entire cache has been cleared.&amp;quot;));
            }
            return (purge);
        }

    } else {
        # Other domains - 404
        return (synth(404, &amp;quot;Domain not found&amp;quot;));
    }

    if (req.method != &amp;quot;GET&amp;quot; &amp;amp;&amp;amp; req.method != &amp;quot;HEAD&amp;quot;) {
        return (pipe);
    }

    return (hash);
}

sub vcl_backend_response {
    # TTL - it-notes.dragas.net
    if (bereq.http.host == &amp;quot;it-notes.dragas.net&amp;quot;) {
        if (bereq.url ~ &amp;quot;\.(gif|jpg|jpeg|png|webp|ico|css|js)$&amp;quot;) {
            set beresp.ttl = 1w;
            set beresp.grace = 1d;
            set beresp.keep = 7d;
            unset beresp.http.Set-Cookie;
            unset beresp.http.Cache-Control;
            set beresp.http.Cache-Control = &amp;quot;public, max-age=604800&amp;quot;;
        } else {
            set beresp.ttl = 15m;
            set beresp.grace = 48h;
            set beresp.keep = 7d;
        }
    }

    # Remove some headers
    unset beresp.http.Server;
    unset beresp.http.X-Powered-By;
    unset beresp.http.Via;

    return (deliver);
}

sub vcl_deliver {
    # Add X-Cache header
    if (obj.hits &amp;gt; 0) {
        set resp.http.X-Cache = &amp;quot;HIT&amp;quot;;
    } else {
        set resp.http.X-Cache = &amp;quot;MISS&amp;quot;;
    }

    std.log(&amp;quot;Delivering content for &amp;quot; + req.url + &amp;quot; - Cache: &amp;quot; + resp.http.X-Cache);

    # Remove Varnish headers
    unset resp.http.Via;
    unset resp.http.X-Varnish;

    return (deliver);
}

sub vcl_hash {
    hash_data(req.url);
    if (req.http.host) {
        hash_data(req.http.host);
    } else {
        hash_data(server.ip);
    }
    return (lookup);
}

sub vcl_hit {
    return (deliver);
}

sub vcl_miss {
    return (fetch);
}

sub vcl_purge {
    std.log(&amp;quot;Purge executed for &amp;quot; + req.url);
    return (synth(200, &amp;quot;Purge successful&amp;quot;));
}

sub vcl_synth {
    set resp.http.Content-Type = &amp;quot;text/html; charset=utf-8&amp;quot;;
    set resp.http.Retry-After = &amp;quot;5&amp;quot;;
    synthetic({&amp;quot;&amp;lt;!DOCTYPE html&amp;gt;
        &amp;lt;html&amp;gt;
            &amp;lt;head&amp;gt;
                &amp;lt;title&amp;gt;&amp;quot;} + resp.status + &amp;quot; &amp;quot; + resp.reason + {&amp;quot;&amp;lt;/title&amp;gt;
            &amp;lt;/head&amp;gt;
            &amp;lt;body&amp;gt;
                &amp;lt;h1&amp;gt;Status &amp;quot;} + resp.status + &amp;quot; &amp;quot; + resp.reason + {&amp;quot;&amp;lt;/h1&amp;gt;
                &amp;lt;p&amp;gt;&amp;quot;} + resp.reason + {&amp;quot;&amp;lt;/p&amp;gt;
                &amp;lt;h3&amp;gt;Guru Meditation:&amp;lt;/h3&amp;gt;
                &amp;lt;p&amp;gt;XID: &amp;quot;} + req.xid + {&amp;quot;&amp;lt;/p&amp;gt;
                &amp;lt;hr&amp;gt;
                &amp;lt;p&amp;gt;Varnish cache server&amp;lt;/p&amp;gt;
            &amp;lt;/body&amp;gt;
        &amp;lt;/html&amp;gt;
    &amp;quot;});
    return (deliver);
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Start Varnish and check if it starts correctly:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;rcctl start varnishd
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Generating SSL Certificates&lt;/h2&gt;
&lt;p&gt;Before configuring &lt;a href="https://man.openbsd.org/relayd.conf.5"&gt;relayd&lt;/a&gt;, you’ll need to generate SSL certificates. Lego supports many DNS providers and provides clear and comprehensive examples, so I suggest reading its &lt;a href="https://github.com/go-acme/lego?tab=readme-ov-file"&gt;README file&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Certificates generated by Lego are not directly compatible with relayd, so you must generate them in the correct format. Add the &lt;code&gt;-k rsa4096&lt;/code&gt; flag to the Lego command to obtain certificates compatible with relayd.&lt;/p&gt;
&lt;p&gt;Once generated, the certificates will be in a subdirectory of the directory from which the command was launched. For example, if the command is run as root (which is unnecessary, but just for the example), the certificates will be in &lt;code&gt;/root/.lego/certificates/&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;relayd expects certificates in a specific location. Copy them to the appropriate directories. In my example:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code class="language-bash"&gt;obcdn# cp /root/.lego/certificates/it-notes.dragas.net.crt /etc/ssl/
obcdn# cp /root/.lego/certificates/it-notes.dragas.net.key /etc/ssl/private/
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Remember to copy the files to the correct directories when renewing. You can also create symbolic links, this will make your life easier but it’s less secure.&lt;/p&gt;
&lt;h2&gt;Configuring relayd&lt;/h2&gt;
&lt;p&gt;It’s time to configure relayd. The file is &lt;code&gt;/etc/relayd.conf&lt;/code&gt;, and here’s an example configuration:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;log state changes
prefork 10

table &amp;lt;itnotes&amp;gt; { 127.0.0.1 }

http protocol &amp;quot;http&amp;quot; {
    match request header append &amp;quot;X-Forwarded-For&amp;quot; value &amp;quot;$REMOTE_ADDR&amp;quot;
    match request header append &amp;quot;X-Forwarded-By&amp;quot; value &amp;quot;$SERVER_ADDR:$SERVER_PORT&amp;quot;

    match request path &amp;quot;/*.atom&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.css&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.gif&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.html&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.ico&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.jpg&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.webp&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.js&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.png&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.rss&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.svg&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.xml&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.ttf&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.woff2&amp;quot; tag &amp;quot;CACHE&amp;quot;

    match response tagged &amp;quot;CACHE&amp;quot; header set &amp;quot;Cache-Control&amp;quot; value &amp;quot;public, max-age=604800&amp;quot;

    pass request header &amp;quot;Host&amp;quot; value &amp;quot;it-notes.dragas.net&amp;quot; forward to &amp;lt;itnotes&amp;gt;
}

http protocol &amp;quot;https&amp;quot; {
    match request header append &amp;quot;X-Forwarded-For&amp;quot; value &amp;quot;$REMOTE_ADDR&amp;quot;
    match request header append &amp;quot;X-Forwarded-By&amp;quot; value &amp;quot;$SERVER_ADDR:$SERVER_PORT&amp;quot;

    match response header set &amp;quot;Referrer-Policy&amp;quot; value &amp;quot;no-referrer&amp;quot;
    match response header set &amp;quot;X-Content-Type-Options&amp;quot; value &amp;quot;nosniff&amp;quot;
    match response header set &amp;quot;X-Download-Options&amp;quot; value &amp;quot;noopen&amp;quot;
    match response header set &amp;quot;X-Frame-Options&amp;quot; value &amp;quot;SAMEORIGIN&amp;quot;
    match response header set &amp;quot;X-Permitted-Cross-Domain-Policies&amp;quot; value &amp;quot;none&amp;quot;
    match response header set &amp;quot;X-XSS-Protection&amp;quot; value &amp;quot;1; mode=block&amp;quot;
    match response header set &amp;quot;Strict-Transport-Security&amp;quot; value &amp;quot;max-age=15552000; includeSubDomains; preload&amp;quot;

    match request path &amp;quot;/*.atom&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.css&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.gif&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.html&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.ico&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.jpg&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.webp&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.js&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.png&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.rss&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.svg&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.xml&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.ttf&amp;quot; tag &amp;quot;CACHE&amp;quot;
    match request path &amp;quot;/*.woff2&amp;quot; tag &amp;quot;CACHE&amp;quot;

    match response tagged &amp;quot;CACHE&amp;quot; header set &amp;quot;Cache-Control&amp;quot; value &amp;quot;public, max-age=604800&amp;quot;
    tcp { nodelay, sack, socket buffer 65536, backlog 100 }

    tls { keypair &amp;quot;it-notes.dragas.net&amp;quot; }

    pass request header &amp;quot;Host&amp;quot; value &amp;quot;it-notes.dragas.net&amp;quot; forward to &amp;lt;itnotes&amp;gt;
}

relay &amp;quot;http&amp;quot; {
    listen on vio0 port 80
    protocol &amp;quot;http&amp;quot;

    forward to &amp;lt;itnotes&amp;gt; port 8080
}

relay &amp;quot;https&amp;quot; {
    listen on vio0 port 443 tls
    protocol &amp;quot;https&amp;quot;

    forward to &amp;lt;itnotes&amp;gt; port 8080
}

relay &amp;quot;https6&amp;quot; {
    listen on my:ip:v6:address::1 port 443 tls
    protocol &amp;quot;https&amp;quot;

    forward to &amp;lt;itnotes&amp;gt; port 8080
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The content of the file is quite self-explanatory. Note that:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;vio0&lt;/code&gt; is the interface name-it should be modified based on the interface where relayd needs to listen.&lt;/li&gt;
&lt;li&gt;I’ve configured relayd to listen on port 80 as well.&lt;/li&gt;
&lt;li&gt;IPv4 and IPv6 listeners are separated. If IPv6 is not configured, simply comment out that part. Please, if you can, use IPv6. In a fairer world, everyone would have the right to at least one class of public addresses without having to pay exorbitant fees.&lt;/li&gt;
&lt;li&gt;The "keypair" must correspond to the certificate and key names in &lt;code&gt;/etc/ssl&lt;/code&gt; and &lt;code&gt;/etc/ssl/private&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Test the configuration, enable, and start relayd:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;obcdn# relayd -n
configuration OK
obcdn# rcctl enable relayd
obcdn# rcctl start relayd
relayd(ok)
&lt;/code&gt;&lt;/pre&gt;

&lt;h2&gt;Final Checks&lt;/h2&gt;
&lt;p&gt;The stack is ready. A &lt;code&gt;ps&lt;/code&gt; command will show the process status:&lt;/p&gt;
&lt;pre class="highlight"&gt;&lt;code&gt;obcdn# ps aux
USER       PID %CPU %MEM   VSZ   RSS TT  STAT   STARTED       TIME COMMAND
root         1  0.0  0.0   920   276 ??  I       7:10PM    0:00.01 /sbin/init
[...]
_varnish 44999  0.0  0.2  2256  2424 ??  S       8:27PM    0:00.05 varnishd: Varnish-Mgt -i obcdn.my.domain (varnishd)
_varnish 54481  0.0  8.8 34500 88876 ??  S       8:27PM    0:00.60 varnishd: Varnish-Child -i obcdn.my.domain (varnishd)
root     57753  0.0  0.5  4080  4820 ??  IU      8:32PM    0:00.03 /usr/sbin/relayd
_relayd  95940  0.0  0.4  2152  3892 ??  Spc     8:32PM    0:00.01 relayd: pfe (relayd)
_relayd  81032  0.0  0.4  2156  3716 ??  Spc     8:32PM    0:00.01 relayd: hce (relayd)
_relayd  80312  0.0  0.5  2748  5280 ??  Ipc     8:32PM    0:00.03 relayd: relay (relayd)
_relayd  88919  0.0  0.5  2748  5268 ??  Ipc     8:32PM    0:00.04 relayd: relay (relayd)
_relayd  90186  0.0  0.5  2752  5272 ??  Ipc     8:32PM    0:00.03 relayd: relay (relayd)
_relayd  32851  0.0  0.5  2736  5284 ??  Ipc     8:32PM    0:00.03 relayd: relay (relayd)
_relayd  20270  0.0  0.5  2744  5252 ??  Ipc     8:32PM    0:00.03 relayd: relay (relayd)
_relayd  98826  0.0  0.5  2752  5264 ??  Ipc     8:32PM    0:00.04 relayd: relay (relayd)
_relayd   6454  0.0  0.5  2744  5264 ??  Ipc     8:32PM    0:00.03 relayd: relay (relayd)
_relayd  90102  0.0  0.5  2740  5276 ??  Ipc     8:32PM    0:00.03 relayd: relay (relayd)
_relayd  60314  0.0  0.5  2748  5336 ??  Ipc     8:32PM    0:00.03 relayd: relay (relayd)
_relayd  27246  0.0  0.5  2744  5284 ??  Ipc     8:32PM    0:00.03 relayd: relay (relayd)
_relayd  87082  0.0  0.4  2100  4508 ??  Ipc     8:32PM    0:00.02 relayd: ca (relayd)
_relayd  53308  0.0  0.4  2096  4496 ??  Ipc     8:32PM    0:00.02 relayd: ca (relayd)
_relayd  11697  0.0  0.4  2092  4492 ??  Ipc     8:32PM    0:00.02 relayd: ca (relayd)
_relayd  83636  0.0  0.4  2092  4500 ??  Ipc     8:32PM    0:00.02 relayd: ca (relayd)
_relayd  74563  0.0  0.4  2096  4484 ??  Ipc     8:32PM    0:00.02 relayd: ca (relayd)
_relayd  35910  0.0  0.4  2100  4508 ??  Ipc     8:32PM    0:00.02 relayd: ca (relayd)
_relayd  24911  0.0  0.4  2096  4504 ??  Ipc     8:32PM    0:00.02 relayd: ca (relayd)
_relayd  78649  0.0  0.4  2096  4496 ??  Ipc     8:32PM    0:00.02 relayd: ca (relayd)
_relayd  89586  0.0  0.4  2096  4500 ??  Ipc     8:32PM    0:00.02 relayd: ca (relayd)
_relayd  11989  0.0  0.4  2100  4500 ??  Ipc     8:32PM    0:00.02 relayd: ca (relayd)
[...]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In this case, both relayd and Varnish are running correctly.&lt;/p&gt;
&lt;h2&gt;Automating Certificate Renewal&lt;/h2&gt;
&lt;p&gt;As a final step, remember to create a script to renew the certificates, copy them to &lt;code&gt;/etc/ssl&lt;/code&gt; and &lt;code&gt;/etc/ssl/private&lt;/code&gt;, and restart relayd.&lt;/p&gt;
&lt;h2&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;Congratulations, you now have your own free CDN, with your data, fully under your control. Portable, extendable, controllable, and outside of the big providers' grip.&lt;/p&gt;
&lt;p&gt;If your goal is geo-replication, you should use one of the available methods. Some DNS providers allow selection based on the caller's location (like Bunny.net), or, as I prefer, install your own DNS and use tools to manage operations and resolutions, &lt;a href="https://it-notes.dragas.net/2024/08/26/building-a-self-hosted-cdn-for-bsd-cafe-media/"&gt;as briefly described in the previous article&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;With multiple separate reverse proxies, separate DNS servers (on different providers and possibly different countries or continents) capable of checking if the reverse proxies are operational, you can achieve an extremely low likelihood of encountering a Single Point of Failure, as all components, once the cache is filled, will be nearly autonomous even in the event of a (temporary) backend outage - i.e., the original node.&lt;/p&gt;</description><dc:creator xmlns:dc="http://purl.org/dc/elements/1.1/">Stefano Marinelli</dc:creator><pubDate>Thu, 29 Aug 2024 01:41:00 +0200</pubDate><guid isPermaLink="true">https://it-notes.dragas.net/2024/08/29/make-your-own-cdn-openbsd/</guid><category>openbsd</category><category>server</category><category>hosting</category><category>tutorial</category><category>ownyourdata</category><category>vpn</category><category>ha</category><category>wireguard</category><category>web</category><category>cdn</category><category>bsdcafe</category><category>varnish</category><category>series</category></item></channel></rss>