<?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 - nginx</title><link>https://it-notes.dragas.net/categories/nginx/</link><description>Articles in category nginx</description><atom:link href="https://it-notes.dragas.net/categories/nginx/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/nginx/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>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></channel></rss>