<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>blog.thms.uk</title><link>https://blog.thms.uk/</link><description>Michael's blog</description><language>en</language><lastBuildDate>Sat, 15 Aug 2026 06:15:00 +0000</lastBuildDate><atom:link href="https://blog.thms.uk/rss.xml" rel="self" type="application/rss+xml"/><item><title>Reducing Docker deploy downtime for Laravel and FrankenPHP</title><link>https://blog.thms.uk/2026/08/docker-deploy-downtime-laravel-frankenphp/</link><guid>https://blog.thms.uk/2026/08/docker-deploy-downtime-laravel-frankenphp/</guid><pubDate>Sat, 15 Aug 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/php/">php</category><category domain="https://blog.thms.uk/tag/tools/">tools</category><category domain="https://blog.thms.uk/tag/laravel/">laravel</category><category domain="https://blog.thms.uk/tag/docker/">docker</category><description>Every docker compose up -d took my site down longer than I liked. Three fixes: bake caches into the image, do the volume setup once, and split the deploy so the old container keeps serving.</description><content:encoded>&lt;h1 id="reducing-docker-deploy-downtime-for-laravel-and-frankenphp"&gt;Reducing Docker deploy downtime for Laravel and FrankenPHP&lt;/h1&gt;
&lt;p&gt;A little while ago I wrote about &lt;a href="https://blog.thms.uk/2026/06/laravel-frankenphp-dockerfile/"&gt;the multi-stage Dockerfile I put together for Laravel and FrankenPHP&lt;/a&gt;. It worked, the site went live, and I was quietly pleased with myself.&lt;/p&gt;
&lt;p&gt;Then I started deploying to it a few times, and noticed something I hadn&amp;rsquo;t realised when I wrote it: every deploy took the site down for an uncomfortably long time. Not catastrophic - it&amp;rsquo;s a small site on my homelab, not a bank - but long enough to annoy me.&lt;/p&gt;
&lt;p&gt;So this is the follow-up: what was actually taking so long, and what I did to speed it up.&lt;/p&gt;
&lt;h2 id="what-was-actually-happening"&gt;What was actually happening&lt;/h2&gt;
&lt;p&gt;The first thing worth doing was to stop guessing and think properly about the actual sequence of what happens when I run &lt;code&gt;docker compose up -d&lt;/code&gt; after pulling a new image, given my &lt;code&gt;docker-compose.yml&lt;/code&gt;:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;All running containers stop. &lt;strong&gt;The site is now down.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;init&lt;/code&gt; container is created, boots Laravel, runs &lt;code&gt;php artisan migrate --force&lt;/code&gt;, and exits.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;app&lt;/code&gt; container is created. Its entrypoint runs a recursive &lt;code&gt;chown&lt;/code&gt; over the bind mounts, then &lt;code&gt;php artisan optimize&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;FrankenPHP finally starts and binds &lt;code&gt;:80&lt;/code&gt;. &lt;strong&gt;The site is back.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Everything between steps 1 and 4 is downtime, and it&amp;rsquo;s all strictly serial.&lt;/p&gt;
&lt;p&gt;And there is a further wrinkle: my &lt;code&gt;init&lt;/code&gt;, &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;queue&lt;/code&gt; and &lt;code&gt;scheduler&lt;/code&gt; services all run the same image, which means they all run the same entrypoint, which means all four of them were also running that recursive &lt;code&gt;chown&lt;/code&gt; over the same two bind mounts at the same time! Four containers, two mounts, all fighting over the same disk. Ouch!&lt;/p&gt;
&lt;h2 id="fix-1-bake-everything-that-doesnt-depend-on-the-environment-into-the-image"&gt;Fix 1: Bake everything that doesn&amp;rsquo;t depend on the environment into the image&lt;/h2&gt;
&lt;p&gt;The first thing to realise is that &lt;code&gt;optimize&lt;/code&gt; is doing two quite different kinds of work:&lt;/p&gt;
&lt;p&gt;Compiled Blade views and the event-to-listener map are derived &lt;em&gt;purely from the source tree&lt;/em&gt;. They cannot change between building the image and running it. Config and route caching, on the other hand, read my &lt;code&gt;.env&lt;/code&gt;, so they genuinely have to happen at runtime, on the machine, with the real environment present.&lt;/p&gt;
&lt;p&gt;Which means half of &lt;code&gt;optimize&lt;/code&gt; had no business being in the entrypoint at all. It belongs in the image, where it&amp;rsquo;s done once at build time and doesn&amp;rsquo;t hold up boot at all:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;RUN php artisan view:cache --no-interaction \&lt;br&gt;&amp;nbsp;&amp;amp;&amp;amp; php artisan event:cache --no-interaction&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And in the entrypoint, &lt;code&gt;php artisan optimize&lt;/code&gt; becomes just the two that need the environment:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;php artisan config:cache &amp;amp;&amp;amp; php artisan route:cache&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Config first, incidentally, because route definitions can read config values.&lt;/p&gt;
&lt;h3 id="the-gotcha-that-caught-me-out-the-first-time"&gt;The gotcha that caught me out the first time&lt;/h3&gt;
&lt;p&gt;Compiled Blade views normally live in &lt;code&gt;storage/framework/views&lt;/code&gt;. But &lt;code&gt;storage/&lt;/code&gt; is a &lt;strong&gt;bind mount&lt;/strong&gt;, because it&amp;rsquo;s where the logs and file storage need to persist. So anything I bake into &lt;code&gt;/app/storage/framework/views&lt;/code&gt; at build time gets yanked out from under my feet by the host directory the moment the container starts. Yes, the cache is there in the image, but invisible at runtime, and everything is uncached. Second ouch!&lt;/p&gt;
&lt;p&gt;The fix is to put the compiled views somewhere that &lt;em&gt;isn&amp;rsquo;t&lt;/em&gt; a mount:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ENV VIEW_COMPILED_PATH=/app/bootstrap/cache/views&lt;br&gt;&lt;br&gt;RUN mkdir -p &amp;#34;$VIEW_COMPILED_PATH&amp;#34; \&lt;br&gt;&amp;nbsp;&amp;amp;&amp;amp; php artisan view:cache --no-interaction \&lt;br&gt;&amp;nbsp;&amp;amp;&amp;amp; php artisan event:cache --no-interaction&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;VIEW_COMPILED_PATH&lt;/code&gt; might be a bit obscure, but it&amp;rsquo;s an env var built into Laravel to configure exactly where the compiled views are stored.&lt;/p&gt;
&lt;p&gt;One thing to watch: this &lt;code&gt;ENV&lt;/code&gt; line has to come &lt;em&gt;before&lt;/em&gt; the &lt;code&gt;chown&lt;/code&gt; in the Dockerfile, so the compiled views end up owned by &lt;code&gt;www-data&lt;/code&gt; along with the rest of &lt;code&gt;bootstrap/cache&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="fix-2-do-the-volume-setup-once-not-four-times"&gt;Fix 2: Do the volume setup once, not four times&lt;/h2&gt;
&lt;p&gt;This is the concurrent-&lt;code&gt;chown&lt;/code&gt; problem from earlier.&lt;/p&gt;
&lt;p&gt;The entrypoint scaffolds Laravel&amp;rsquo;s storage directories, touches the SQLite file, and chowns it all to &lt;code&gt;www-data&lt;/code&gt;. That work does need doing - on a fresh host those bind mounts start empty and owned by root - but it needs doing &lt;em&gt;once&lt;/em&gt;, not simultaneously in every container.&lt;/p&gt;
&lt;p&gt;So, it needs to go into the &lt;code&gt;init&lt;/code&gt; server, together with the migration and caching: do it once before any of the other services start. I put the whole block behind an environment variable:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;if [ &amp;#34;${BOOTSTRAP_VOLUMES:-0}&amp;#34; = &amp;#34;1&amp;#34; ]; then&lt;br&gt;&amp;nbsp;&amp;nbsp;mkdir -p \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;storage/framework/cache/data \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;storage/framework/sessions \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;storage/framework/views \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;storage/logs \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;storage/app/public&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;mkdir -p &amp;#34;$(dirname &amp;#34;$DB_FILE&amp;#34;)&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;touch &amp;#34;$DB_FILE&amp;#34;&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;chown -R &amp;#34;$APP_USER&amp;#34; storage &amp;#34;$(dirname &amp;#34;$DB_FILE&amp;#34;)&amp;#34; 2&amp;gt;/dev/null || true&lt;br&gt;&amp;nbsp;&amp;nbsp;chmod -R ug+rwX storage &amp;#34;$(dirname &amp;#34;$DB_FILE&amp;#34;)&amp;#34; 2&amp;gt;/dev/null || true&lt;br&gt;fi&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and set &lt;code&gt;BOOTSTRAP_VOLUMES: &amp;quot;1&amp;quot;&lt;/code&gt; on the init service only:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# ...&lt;br&gt;&amp;nbsp;&amp;nbsp;init:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;&amp;lt;: *app-common&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;command: php artisan migrate --force&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;environment:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;BOOTSTRAP_VOLUMES: &amp;#34;1&amp;#34;&lt;br&gt;# ...&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="fix-3-deploying-in-stages"&gt;Fix 3: Deploying in stages&lt;/h2&gt;
&lt;p&gt;The changes above shorten the work, but the sequence is still fundamentally &amp;ldquo;stop the old container, do some stuff, start the new one&amp;rdquo;. The other half of the problem is that &lt;code&gt;docker compose up -d&lt;/code&gt; recreates &lt;em&gt;everything&lt;/em&gt; at once, so the web container&amp;rsquo;s downtime includes waiting for containers that have nothing to do with serving requests.&lt;/p&gt;
&lt;p&gt;Splitting the deploy into three commands fixes that:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose pull&lt;br&gt;docker compose up -d app&lt;br&gt;docker compose up -d --no-deps queue scheduler &lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You might wonder why no &lt;code&gt;docker compose up -d init&lt;/code&gt;: Because the &lt;code&gt;app&lt;/code&gt; service &lt;code&gt;depends_on: init&lt;/code&gt;, the &lt;code&gt;init&lt;/code&gt; service will run and complete before the &lt;code&gt;app&lt;/code&gt; service is recreated.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;--no-deps&lt;/code&gt; on the last line ensures that we are not running the &lt;code&gt;init&lt;/code&gt; container for a second time, when we recreate the &lt;code&gt;queue&lt;/code&gt; and &lt;code&gt;scheduler&lt;/code&gt; containers.&lt;/p&gt;
&lt;h3 id="the-trade-off"&gt;The trade-off&lt;/h3&gt;
&lt;p&gt;Keeping the site up during migrations means the old code is now serving requests (and processing the queue and scheduled tasks) against the new schema. For additive migrations that&amp;rsquo;s fine. For anything destructive (dropping a column, renaming one, tightening a constraint) the old container will throw errors for the short period between init finishing and the swap.&lt;/p&gt;
&lt;p&gt;But then this is just what modern development looks like to me: I&amp;rsquo;ve done zero-downtime deployments for years, so I&amp;rsquo;m used to planning deployment in such a way that my old code can run with new schema. When I do need to do destructive migrations I&amp;rsquo;ll have to phase them over two deployments. That is just business as usual.&lt;/p&gt;
&lt;p&gt;Alternatively, you can always &lt;code&gt;stop&lt;/code&gt; the &lt;code&gt;cron&lt;/code&gt; and &lt;code&gt;scheduler&lt;/code&gt; services before you run &lt;code&gt;docker compose up -d app&lt;/code&gt;, to stop these running during that restart window.&lt;/p&gt;
&lt;h2 id="takeaway"&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;With Docker, as with bare code deployments, it pays to think about the sequence of deployment steps. That sequence is a bit more obvious when you&amp;rsquo;re deploying the code itself rather than building an image, pulling it, and restarting containers. But fundamentally the same things matter.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/117099753349970966"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>When restart: always isn't enough: making Compose stacks recover at boot</title><link>https://blog.thms.uk/2026/08/docker-compose-autostart-boot/</link><guid>https://blog.thms.uk/2026/08/docker-compose-autostart-boot/</guid><pubDate>Mon, 10 Aug 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/mastodon/">mastodon</category><category domain="https://blog.thms.uk/tag/selfhosting/">selfhosting</category><category domain="https://blog.thms.uk/tag/homelab/">homelab</category><category domain="https://blog.thms.uk/tag/docker/">docker</category><description>Restart policies only cover containers that have already started. When AppArmor blocked dockerd's default profile at boot, thirteen containers stayed down for four hours. Here's my fix.</description><content:encoded>&lt;h1 id="when-restart-always-isnt-enough-making-compose-stacks-recover-at-boot"&gt;When restart: always isn&amp;rsquo;t enough: making Compose stacks recover at boot&lt;/h1&gt;
&lt;p&gt;My Mastodon instance runs on Docker Compose, in an LXC container on a Proxmox host at home. The host
reboots most weeks to pick up kernel updates, and that has never been a problem: every service in my
compose files has &lt;code&gt;restart: always&lt;/code&gt; or &lt;code&gt;restart: unless-stopped&lt;/code&gt;, so everything comes back on its
own.&lt;/p&gt;
&lt;p&gt;Until one morning, when it didn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;My monitoring did its job and alerted me. However, the reboot is scheduled in the small hours, so I
was fast asleep, and the instance was down for four hours before I got round to looking at it.&lt;/p&gt;
&lt;p&gt;When I did look, &lt;code&gt;docker ps&lt;/code&gt; was empty. Not a list of crashed containers - nothing running at all.&lt;/p&gt;
&lt;p&gt;The first thing I did was bring everything back up by hand:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That worked, first time, in every project directory. Which was actually kinda annoying, because it
meant whatever had gone wrong had already gone away, and I now had to find it after the fact.&lt;/p&gt;
&lt;h2 id="the-problem"&gt;The problem&lt;/h2&gt;
&lt;p&gt;The compose files were fine - I checked the restart policies first, and every service had one. So if
the containers weren&amp;rsquo;t running, the daemon was the place to look:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo journalctl -u docker.service -b&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And there it was:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;level=error msg=&amp;#34;Failed to load default apparmor profile&amp;#34; error=&amp;#34;AppArmor enabled on
system but the docker-default profile could not be loaded: running
&amp;#39;/usr/sbin/apparmor_parser -Kr&amp;#39; failed with output: apparmor_parser: Unable to
replace &amp;#34;docker-default&amp;#34;. apparmor_parser: Access denied. You need policy admin
privileges to manage profiles. error: exit status 243&amp;#34;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Followed by thirteen of these, one per container:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;level=error msg=&amp;#34;failed to start container&amp;#34; container=518ed98356409f...
error=&amp;#34;AppArmor enabled on system but the docker-default profile could not be
loaded: ... error: exit status 243&amp;#34;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And then, immediately after:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;level=info msg=&amp;#34;Loading containers: done.&amp;#34;
level=info msg=&amp;#34;Daemon has completed initialization&amp;#34;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So Docker couldn&amp;rsquo;t load its default AppArmor profile, every container refused to start as a result,
and the daemon then declared itself fully up and left me hanging.&lt;/p&gt;
&lt;p&gt;Worth knowing: &lt;code&gt;restart: always&lt;/code&gt; does not help here. The restart policy governs what happens when a
running container exits. When the daemon restores containers at startup it makes exactly one attempt
each, and if that attempt fails, the policy never comes into play. Nothing retries. The daemon
reports itself healthy with zero containers running, and sits there indefinitely.&lt;/p&gt;
&lt;h2 id="chasing-the-cause"&gt;Chasing the cause&lt;/h2&gt;
&lt;p&gt;I spent a while trying to work out why the profile load was denied, and I should say up front that I
haven&amp;rsquo;t yet got to the bottom of it. Here&amp;rsquo;s what I ruled out, in case it saves someone else the
time.&lt;/p&gt;
&lt;p&gt;It wasn&amp;rsquo;t specific to Docker. Two seconds before dockerd tried, rsyslogd had failed the same way:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;reload-apparmor-profile[136]: apparmor_parser: Unable to replace &amp;#34;rsyslogd&amp;#34;.
apparmor_parser: Access denied. You need policy admin privileges to manage profiles.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;So nothing inside the LXC container could load AppArmor policy at that moment.&lt;/p&gt;
&lt;p&gt;My first theory was a startup race on the Proxmox host: the container starting before the host had
finished setting up AppArmor. That turned out to be wrong. The host&amp;rsquo;s &lt;code&gt;apparmor.service&lt;/code&gt; finished at
02:01:16 and the container started at 02:01:28, a full twelve seconds later.&lt;/p&gt;
&lt;p&gt;Nesting is enabled on the container, so that wasn&amp;rsquo;t it either.&lt;/p&gt;
&lt;p&gt;And it doesn&amp;rsquo;t reproduce. I ran &lt;code&gt;pct reboot&lt;/code&gt; on the container against the already-running host, and
all thirteen came back perfectly. It&amp;rsquo;s also intermittent across host reboots - the host reboots most
weeks, and every previous one has been fine.&lt;/p&gt;
&lt;p&gt;So: something denied AppArmor policy loading inside the container for a window after the host
booted, then quietly stopped denying it. I don&amp;rsquo;t know what, and I can&amp;rsquo;t trigger it on demand.&lt;/p&gt;
&lt;h3 id="one-dead-end-worth-flagging"&gt;One dead end worth flagging&lt;/h3&gt;
&lt;p&gt;While testing this I reached for an obvious-looking check - try loading an existing profile and see
if it works:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apparmor_parser -Kr /etc/apparmor.d/usr.sbin.rsyslogd&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This fails with &amp;ldquo;Access denied&amp;rdquo; every single time, whether or not Docker is healthy. It looks
exactly like a reproduction, and it isn&amp;rsquo;t one. That profile is loaded by the Proxmox host, and a
nested container can&amp;rsquo;t &lt;em&gt;replace&lt;/em&gt; a profile owned by the parent namespace. It&amp;rsquo;s a different
permission failing for a different reason.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also no &lt;code&gt;/etc/apparmor.d/docker&lt;/code&gt; to test with, because dockerd doesn&amp;rsquo;t read its profile from
a file - it generates &lt;code&gt;docker-default&lt;/code&gt; at runtime and pipes it to &lt;code&gt;apparmor_parser&lt;/code&gt;. So if you want
to test the permission Docker actually needs, load a throwaway profile of your own:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;printf &amp;#39;profile probe {\n file,\n}\n&amp;#39; | sudo apparmor_parser -a -; echo &amp;#34;rc=$?&amp;#34;&lt;br&gt;printf &amp;#39;profile probe {\n file,\n}\n&amp;#39; | sudo apparmor_parser -R -&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="the-solution"&gt;The solution&lt;/h2&gt;
&lt;p&gt;Since I couldn&amp;rsquo;t find the trigger, and it clears itself, I stopped trying to prevent it and made the
recovery automatic instead.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s needed is something that runs after Docker at boot, brings every stack up, and - crucially -
&lt;em&gt;keeps trying&lt;/em&gt; if it doesn&amp;rsquo;t work the first time. That last part is the whole point. A one-shot
&lt;code&gt;docker compose up -d&lt;/code&gt; at boot would have failed on that particular morning exactly like the daemon
did.&lt;/p&gt;
&lt;p&gt;So I wrote &lt;a href="https://code.thms.uk/michael/mastodon-compose/src/branch/main/compose-autostart.sh"&gt;compose-autostart.sh&lt;/a&gt;: it finds every
compose project in the &lt;code&gt;BASE_DIR&lt;/code&gt; directory, brings each one up, drops the ones that succeeded, and retries the
rest every 30 seconds for up to an hour. On a normal boot it does one round and exits.&lt;/p&gt;
&lt;p&gt;Two things I should highlight here.&lt;/p&gt;
&lt;p&gt;This script only runs at boot, not on a timer. My backup script deliberately stops each stack while
it snapshots it, and a reconciler running on a schedule would happily start them again mid-backup.
For the same reason, the script skips any round where the backup is holding its lock.&lt;/p&gt;
&lt;p&gt;And it will bring up any containers that I may have stopped on purpose, since it doesn&amp;rsquo;t know what was and
wasn&amp;rsquo;t running by the time the host rebooted.&lt;/p&gt;
&lt;h2 id="making-the-solution-permanent"&gt;Making the solution permanent&lt;/h2&gt;
&lt;p&gt;The unit itself is unremarkable:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]&lt;br&gt;Description=Bring all Docker Compose stacks up at boot, retrying until they start&lt;br&gt;Requires=docker.service&lt;br&gt;After=docker.service network-online.target&lt;br&gt;Wants=network-online.target&lt;br&gt;&lt;br&gt;[Service]&lt;br&gt;Type=oneshot&lt;br&gt;RemainAfterExit=yes&lt;br&gt;ExecStart=/path/to/compose-autostart.sh&lt;br&gt;TimeoutStartSec=infinity&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;One systemd wrinkle: you can&amp;rsquo;t use &lt;code&gt;Restart=&lt;/code&gt; with &lt;code&gt;Type=oneshot&lt;/code&gt;, so the retry loop has to live in
the script rather than in the unit. &lt;code&gt;TimeoutStartSec=infinity&lt;/code&gt; is there because the script owns its
own deadline.&lt;/p&gt;
&lt;p&gt;Enable the service with&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl daemon-reload&lt;br&gt;sudo systemctl enable compose-autostart.service&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="wrapping-up"&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;I haven&amp;rsquo;t yet found out what denied AppArmor policy loading that morning. I&amp;rsquo;d love to know if you
have any theories.&lt;/p&gt;
&lt;p&gt;The more important part for now is that - hopefully - a failure like this should in future clear itself:
retrying in a loop turns a middle-of-the-night outage into something that&amp;rsquo;s already fixed by the
time I wake up.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/117072919537902132"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Exempting an IP from Mastodon's rate limits</title><link>https://blog.thms.uk/2026/08/exempting-ip-from-mastodon-rate-limit/</link><guid>https://blog.thms.uk/2026/08/exempting-ip-from-mastodon-rate-limit/</guid><pubDate>Thu, 06 Aug 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/mastodon/">mastodon</category><category domain="https://blog.thms.uk/tag/fedifetcher/">fedifetcher</category><category domain="https://blog.thms.uk/tag/ruby/">ruby</category><description>My FediFetcher install kept tripping my own instance's rate limits. A Rack::Attack initialiser let me safelist its IP, once I'd worked out which IP Rails actually sees.</description><content:encoded>&lt;h1 id="exempting-an-ip-from-mastodons-rate-limits"&gt;Exempting an IP from Mastodon&amp;rsquo;s rate limits&lt;/h1&gt;
&lt;p&gt;I have always wanted to exempt my &lt;a href="https://blog.thms.uk/fedifetcher"&gt;FediFetcher&lt;/a&gt; install from Mastodon&amp;rsquo;s rate limits: rate limits are of course really crucial to the security of a (web) application, but when I run my own FediFetcher against my own instance, the frequent resolving of remote posts triggers the rate limits a lot and really slows it down.&lt;/p&gt;
&lt;p&gt;Unfortunately, Mastodon has no configuration option to do this, but it uses the &lt;a href="https://github.com/rack/rack-attack"&gt;Rack::Attack&lt;/a&gt; gem, and that can be configured by simply adding another file to the file system. So let&amp;rsquo;s get to work.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The commands below assume a traditional systemd install. The initialisers themselves are identical on Docker Compose, but you&amp;rsquo;ll need to get the files into the container and restart it differently: I&amp;rsquo;ve implemented the same thing (plus pinning an IP address for my FediFetcher container) in &lt;a href="https://code.thms.uk/michael/mastodon-compose/commit/0411966e82087751cc53f2ea55e47e133151fe20"&gt;my mastodon-compose project&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="confirming-the-ip-address-rackattack-actually-sees"&gt;Confirming the IP address Rack::Attack actually sees&lt;/h2&gt;
&lt;p&gt;Since this is security critical, I really wanted to make sure I&amp;rsquo;d got the IP addresses configured correctly, rather than relying on assumptions. So the first thing to do is log the IP addresses Rack::Attack sees. Create a file at &lt;code&gt;config/initializers/zz_rack_attack_safelist.rb&lt;/code&gt;. (The &lt;code&gt;zz_&lt;/code&gt; prefix ensures it loads after Mastodon&amp;rsquo;s own &lt;code&gt;rack_attack.rb&lt;/code&gt;, since Rails loads initializers in alphabetical order.) Give it the following content:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# frozen_string_literal: true&lt;br&gt;#&lt;br&gt;# Logs whenever a throttle or blocklist matches, so you can see which rule fired&lt;br&gt;# and for which IP.&lt;br&gt;ActiveSupport::Notifications.subscribe(/rack_attack/) do |_name, _start, _finish, _id, payload|&lt;br&gt;&amp;nbsp;&amp;nbsp;req = payload[:request]&lt;br&gt;&amp;nbsp;&amp;nbsp;next if req.nil?&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;Rails.logger.warn(&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;[rack_attack] type=#{req.env[&amp;#39;rack.attack.match_type&amp;#39;]} &amp;#34; \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;rule=#{req.env[&amp;#39;rack.attack.matched&amp;#39;]} &amp;#34; \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;remote_ip=#{req.remote_ip} raw_ip=#{req.ip} &amp;#34; \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;xff=#{req.get_header(&amp;#39;HTTP_X_FORWARDED_FOR&amp;#39;).inspect} path=#{req.path}&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;)&lt;br&gt;end&lt;br&gt;&lt;br&gt;# Logs the IP for every API request without ever safelisting anything&lt;br&gt;# (the block always returns false). Run FediFetcher and read remote_ip out of &lt;br&gt;# the logs&lt;br&gt;Rack::Attack.safelist(&amp;#39;probe (never matches)&amp;#39;) do |req|&lt;br&gt;if req.path.start_with?(&amp;#39;/api/&amp;#39;)&lt;br&gt;&amp;nbsp;&amp;nbsp;Rails.logger.warn(&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;[rack_attack probe] remote_ip=#{req.remote_ip} raw_ip=#{req.ip} &amp;#34; \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;xff=#{req.get_header(&amp;#39;HTTP_X_FORWARDED_FOR&amp;#39;).inspect} &amp;#34; \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;ua=#{req.get_header(&amp;#39;HTTP_USER_AGENT&amp;#39;).inspect} path=#{req.path}&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;)&lt;br&gt;end&lt;br&gt;&lt;br&gt;false&lt;br&gt;end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now, restart Mastodon:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl restart mastodon-web&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Read the logs while FediFetcher is running:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;journalctl -u mastodon-web -f | grep &amp;#39;rack_attack probe&amp;#39;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should find lines that look a bit like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;[rack_attack probe] remote_ip=172.20.0.2 raw_ip=172.19.0.1 xff=&amp;#34;172.20.0.2&amp;#34; ua=&amp;#34;FediFetcher/8.0.0; +mstdn.thms.uk (https://go.thms.uk/ff)&amp;#34; path=/api/v2/search
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The value you want is &lt;code&gt;remote_ip&lt;/code&gt;, not &lt;code&gt;raw_ip&lt;/code&gt;: that&amp;rsquo;s the address Rails derives after stripping trusted proxies, and it&amp;rsquo;s what the safelist below compares against.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s important to sanity check that this isn&amp;rsquo;t your reverse proxy&amp;rsquo;s IP. Stop here and fix your reverse proxy if it is!&lt;/p&gt;
&lt;h2 id="safelist-the-ip"&gt;Safelist the IP&lt;/h2&gt;
&lt;p&gt;Edit &lt;code&gt;config/initializers/zz_rack_attack_safelist.rb&lt;/code&gt; and replace the whole contents with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# frozen_string_literal: true&lt;br&gt;#&lt;br&gt;# safelist by IP as specified in env&lt;br&gt;RACK_ATTACK_SAFELIST_IPS = ENV.fetch(&amp;#39;RACK_ATTACK_SAFELIST_IPS&amp;#39;, &amp;#39;&amp;#39;)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.split(&amp;#39;,&amp;#39;)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.map(&amp;amp;:strip)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.reject(&amp;amp;:empty?)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.filter_map do |cidr|&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;begin&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;IPAddr.new(cidr)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rescue IPAddr::Error =&amp;gt; e&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Rails.logger.error(&amp;#34;[rack_attack] ignoring invalid RACK_ATTACK_SAFELIST_IPS entry #{cidr.inspect}: #{e.message}&amp;#34;)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;nil&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;end&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;end&lt;br&gt;&lt;br&gt;unless RACK_ATTACK_SAFELIST_IPS.empty?&lt;br&gt;&amp;nbsp;&amp;nbsp;Rails.logger.info(&amp;#34;[rack_attack] safelisting #{RACK_ATTACK_SAFELIST_IPS.map(&amp;amp;:to_s).join(&amp;#39;, &amp;#39;)}&amp;#34;)&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;Rack::Attack.safelist(&amp;#39;allow from safelisted clients&amp;#39;) do |req|&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;begin&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ip = IPAddr.new(req.remote_ip)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;RACK_ATTACK_SAFELIST_IPS.any? { |net| net.include?(ip) }&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rescue IPAddr::Error&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;false&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;end&lt;br&gt;&amp;nbsp;&amp;nbsp;end&lt;br&gt;end&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;A safelist match short-circuits everything else, so a matching request skips all of Mastodon&amp;rsquo;s throttles and blocklists.&lt;/p&gt;
&lt;p&gt;Then add the following to your &lt;code&gt;.env.production&lt;/code&gt; file:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code class="language-dotenv" data-lang="dotenv"&gt;# Replace with whatever IP address you found above. Separate multiple IPs with
# commas. CIDR ranges are also supported.
RACK_ATTACK_SAFELIST_IPS=172.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Restart Mastodon again and read the logs during boot:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl restart mastodon-web &amp;amp;&amp;amp; journalctl -u mastodon-web -f | grep &amp;#39;rack_attack&amp;#39;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You should find a line saying&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;[rack_attack] safelisting 172.20.0.2
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="security"&gt;Security&lt;/h2&gt;
&lt;p&gt;You are now giving one IP (or several) totally unfettered access to your Mastodon instance, so you really want to make sure your reverse proxy has its trusted proxy addresses locked down properly. If it doesn&amp;rsquo;t, &lt;code&gt;remote_ip&lt;/code&gt; is derived from an &lt;code&gt;X-Forwarded-For&lt;/code&gt; header anyone can set, and your safelist becomes trivially spoofable. But that&amp;rsquo;s beyond the scope of this post.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/117052829614462675"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Setting up OIDC for Mastodon with Pocket ID</title><link>https://blog.thms.uk/2026/07/mastodon-oidc-setup/</link><guid>https://blog.thms.uk/2026/07/mastodon-oidc-setup/</guid><pubDate>Sun, 19 Jul 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/mastodon/">mastodon</category><description>Setting up Pocket ID as an OIDC provider for a self-hosted Mastodon instance, including the undocumented settings needed to link an existing account without locking yourself out.</description><content:encoded>&lt;h1 id="setting-up-oidc-for-mastodon-with-pocket-id"&gt;Setting up OIDC for Mastodon with Pocket ID&lt;/h1&gt;
&lt;p&gt;Today I wanted to experiment with setting up OIDC (specifically Pocket ID) for my Mastodon instance. Annoyingly, there is very little documentation about this, but here is what I gathered.&lt;/p&gt;
&lt;h2 id="oidc-provider-setup"&gt;OIDC provider setup&lt;/h2&gt;
&lt;p&gt;Now, this setup will vary slightly depending on the OIDC provider you are using. I&amp;rsquo;m writing up the Pocket ID steps here, but they should transfer to other providers.&lt;/p&gt;
&lt;p&gt;The main thing you need to provide is the callback URL, which is &lt;code&gt;https://example.com/auth/auth/openid_connect/callback&lt;/code&gt;, where &lt;code&gt;example.com&lt;/code&gt; is your Mastodon domain. (Yes, double &lt;code&gt;auth&lt;/code&gt;. No, I don&amp;rsquo;t know why 🤷‍♂️)&lt;/p&gt;
&lt;p&gt;You should also enable PKCE.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;ll then be given a Client ID and Client Secret. Keep a note of these.&lt;/p&gt;
&lt;h2 id="mastodon-setup"&gt;Mastodon setup&lt;/h2&gt;
&lt;p&gt;The minimal setup requires adding the following to your &lt;code&gt;.env.production&lt;/code&gt; file. The comments explain each option.&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code class="language-dotenv" data-lang="dotenv"&gt;OIDC_ENABLED=true # The main switch to turn it on
OIDC_DISCOVERY=true # Required if you don&amp;#39;t want to manually provide all the OIDC endpoints. Depends on your provider exposing these through the `/.well-known/openid-configuration` endpoint (which Pocket ID does)
OIDC_USE_PKCE=true # If you turned on PKCE when registering Mastodon with your OIDC provider. Turn on both or neither, but never one or the other
OIDC_DISPLAY_NAME=&amp;#34;&amp;lt;Your Provider Name&amp;gt;&amp;#34; # Used on the &amp;#34;Log in with &amp;lt;Provider Name&amp;gt;&amp;#34; button
OIDC_ISSUER=&amp;#34;https://id.example.com&amp;#34; # The root URL of your provider
OIDC_SCOPE=&amp;#34;openid,profile,email&amp;#34; # The scopes you need
OIDC_CLIENT_ID=&amp;#34;clientId&amp;#34; # The Client ID provided by your OIDC server
OIDC_CLIENT_SECRET=&amp;#34;clientSecret&amp;#34; # The Client Secret provided by your OIDC server
OIDC_UID_FIELD=&amp;#34;preferred_username&amp;#34; # The field to match on
OIDC_REDIRECT_URI=&amp;#34;https://example.com/auth/auth/openid_connect/callback&amp;#34; # The same callback URL you provided above
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now, there is one thing worth dwelling on: the &lt;code&gt;OIDC_UID_FIELD&lt;/code&gt; setting. This determines how accounts are matched between Mastodon and your OIDC provider. For example, if your OIDC username is &lt;code&gt;michael&lt;/code&gt;, this will create a Mastodon account &lt;code&gt;@michael@example.com&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;If you later change your OIDC username (say, to &lt;code&gt;michael-thomas&lt;/code&gt;) and log in to Mastodon again, Mastodon will see a new user and create &lt;code&gt;@michael-thomas@example.com&lt;/code&gt;. So, yeah, you don&amp;rsquo;t want to change your OIDC username if you do this&amp;hellip;&lt;/p&gt;
&lt;p&gt;A couple of other settings worth considering:&lt;/p&gt;
&lt;p&gt;If you want to disable password login altogether, add &lt;code&gt;OMNIAUTH_ONLY=true&lt;/code&gt; to your &lt;code&gt;.env.production&lt;/code&gt;. Users will then only be able to log in using your OIDC provider.&lt;/p&gt;
&lt;p&gt;If you do that, you may as well also add &lt;code&gt;ONE_CLICK_SSO_LOGIN=true&lt;/code&gt;, which skips Mastodon&amp;rsquo;s login form entirely and goes straight to the OIDC provider.&lt;/p&gt;
&lt;h2 id="switching-to-oidc"&gt;Switching to OIDC&lt;/h2&gt;
&lt;p&gt;There is an interesting wrinkle here: I already had my instance up and running, and so already had an account. When I tried to log in using my OIDC provider I got the message &amp;ldquo;Error creating an account for this identity.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;This is a sensible security feature by Mastodon, which prevents account takeovers from OIDC providers. To let me log in and link my existing &lt;code&gt;@michael@thms.uk&lt;/code&gt; account to Pocket ID&amp;rsquo;s &lt;code&gt;michael&lt;/code&gt; account, I needed to add &lt;code&gt;ALLOW_UNSAFE_AUTH_PROVIDER_REATTACH=true&lt;/code&gt; to my &lt;code&gt;.env.production&lt;/code&gt; too.&lt;/p&gt;
&lt;p&gt;This matches on email address rather than username, though. &lt;strong&gt;So it requires that the OIDC user has the same email address as the Mastodon user you want to link.&lt;/strong&gt; Keep that in mind.&lt;/p&gt;
&lt;p&gt;Make sure you remove it again afterwards, as it isn&amp;rsquo;t called &amp;lsquo;unsafe&amp;rsquo; for nothing. On a single user instance it matters less, since each account can only be attached to an OIDC provider once, but it&amp;rsquo;s still good practice.&lt;/p&gt;
&lt;p&gt;With that done, logging in goes straight through Pocket ID and my instance is one fewer set of credentials to look after. It took far more digging than it should have, so hopefully this saves you some of it.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/117004440239936981"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>TIL: MySQL view security context after a migration</title><link>https://blog.thms.uk/2026/07/mysql-view-definer-migration/</link><guid>https://blog.thms.uk/2026/07/mysql-view-definer-migration/</guid><pubDate>Thu, 16 Jul 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/sql/">sql</category><description>Dumped a database, imported it on a new server, and suddenly couldn't select from my view. Turns out views run as whoever created them, and mine no longer existed.</description><enclosure url="https://media.thms.uk/images/2026-07-16-mysql-view-definer-migration.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="til-mysql-view-security-context-after-a-migration"&gt;TIL: MySQL view security context after a migration&lt;/h1&gt;
&lt;p&gt;Background: I had set up a &lt;a href="https://blog.thms.uk/2024/07/mysql-views-laravel/"&gt;MySQL view on a server&lt;/a&gt;. Then done a server migration by doing the classic dump and import dance.&lt;/p&gt;
&lt;p&gt;After the migration I was suddenly unable to access the view, getting errors like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;SQLSTATE[28000]: Invalid authorization specification:
1045 Access denied for user &amp;#39;&amp;lt;user&amp;gt;&amp;#39;@&amp;#39;&amp;lt;host&amp;gt;&amp;#39; (using password: YES)
(Connection: mysql, Host: &amp;lt;host&amp;gt;, Port: 3306, Database: &amp;lt;database&amp;gt;,
SQL: [SELECT * FROM `view`])
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;My initial thought was that I had made a mistake when defining the users or GRANTS on the new host, but no:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mysql&amp;gt; SELECT user, host FROM mysql.user WHERE user = &amp;#39;&amp;lt;user&amp;gt;&amp;#39;;&lt;br&gt;+-----------+----------+&lt;br&gt;| user | host |&lt;br&gt;+-----------+----------+&lt;br&gt;| &amp;lt;user&amp;gt; | &amp;lt;host&amp;gt; |&lt;br&gt;+-----------+----------+&lt;br&gt;&lt;br&gt;mysql&amp;gt; SHOW GRANTS FOR &amp;#39;&amp;lt;user&amp;gt;&amp;#39;@&amp;#39;&amp;lt;host&amp;gt;&amp;#39;;&lt;br&gt;+----------------------------------------------------------------+&lt;br&gt;| Grants for &amp;lt;user&amp;gt;@&amp;lt;host&amp;gt; |&lt;br&gt;+----------------------------------------------------------------+&lt;br&gt;| GRANT USAGE ON *.* TO `&amp;lt;user&amp;gt;`@`&amp;lt;host&amp;gt;` |&lt;br&gt;| GRANT ALL PRIVILEGES ON `&amp;lt;database&amp;gt;`.* TO `&amp;lt;user&amp;gt;`@`&amp;lt;host&amp;gt;` |&lt;br&gt;+----------------------------------------------------------------+&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That&amp;rsquo;s when I learned about &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/stored-objects-security.html"&gt;&lt;code&gt;SQL SECURITY&lt;/code&gt;&lt;/a&gt; for views. It lets you set the security context that a view or stored program runs under.&lt;/p&gt;
&lt;p&gt;I had originally created the view with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE VIEW `view` AS&lt;br&gt;-- view definition here&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The default security context for views is &lt;code&gt;DEFINER&lt;/code&gt;, and the default definer is whichever account ran the &lt;code&gt;CREATE VIEW&lt;/code&gt;. I&amp;rsquo;d done that on the old host, pre-migration, and &lt;code&gt;mysqldump&lt;/code&gt; writes the &lt;code&gt;DEFINER&lt;/code&gt; clause out into the dump, so it came across to the new server unchanged:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mysql&amp;gt; SELECT DEFINER, SECURITY_TYPE&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;gt; FROM information_schema.VIEWS&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;gt; WHERE TABLE_SCHEMA = &amp;#39;&amp;lt;database&amp;gt;&amp;#39; AND TABLE_NAME = &amp;#39;&amp;lt;view&amp;gt;&amp;#39;;&lt;br&gt;+-------------------+---------------+&lt;br&gt;| DEFINER | SECURITY_TYPE |&lt;br&gt;+-------------------+---------------+&lt;br&gt;| &amp;lt;user&amp;gt;@&amp;lt;old-host&amp;gt; | DEFINER |&lt;br&gt;+-------------------+---------------+&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;See that? &lt;code&gt;&amp;lt;user&amp;gt;@&amp;lt;old-host&amp;gt;&lt;/code&gt; is the definer, and the view runs in that account&amp;rsquo;s security context. That account doesn&amp;rsquo;t exist on the new host, so the view is what MySQL calls an orphan.&lt;/p&gt;
&lt;p&gt;The fix is then quite straightforward:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;CREATE OR REPLACE SQL SECURITY INVOKER VIEW `view` AS&lt;br&gt;-- view definition here&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I chose to change the security context to &lt;code&gt;INVOKER&lt;/code&gt; rather than repointing the definer at the new account. With &lt;code&gt;INVOKER&lt;/code&gt;, MySQL never consults the definer account: it runs the view with the calling connection&amp;rsquo;s own privileges, which in my case is &lt;code&gt;ALL PRIVILEGES&lt;/code&gt; on the database. The trade-off is that the invoker now needs privileges on the underlying tables too, not just on the view itself, so &lt;code&gt;INVOKER&lt;/code&gt; isn&amp;rsquo;t the right call if you&amp;rsquo;re using views to expose a restricted slice of a table to an account that shouldn&amp;rsquo;t see the whole thing.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116928258270178392"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Everything I self-host (and a few things I don't)</title><link>https://blog.thms.uk/2026/07/self-hosted-services/</link><guid>https://blog.thms.uk/2026/07/self-hosted-services/</guid><pubDate>Fri, 10 Jul 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/meta/">meta</category><category domain="https://blog.thms.uk/tag/selfhosting/">selfhosting</category><category domain="https://blog.thms.uk/tag/homelab/">homelab</category><description>A run-through of everything I self-host for myself and my family - from Headscale and Forgejo to Mastodon and Home Assistant - plus the few things I've decided not to.</description><enclosure url="https://media.thms.uk/images/2026-07-06-self-hosted-services.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="everything-i-self-host-and-a-few-things-i-dont"&gt;Everything I self-host (and a few things I don&amp;rsquo;t)&lt;/h1&gt;
&lt;p&gt;I thought it would be interesting to catalogue all the services I host for myself and my family, along with the ones I actually use but don&amp;rsquo;t self-host, and why. It&amp;rsquo;s as much a snapshot for my own reference as anything.&lt;/p&gt;
&lt;h2 id="general-philosophy"&gt;General philosophy&lt;/h2&gt;
&lt;p&gt;All my services (except for my static sites) run as Docker Compose services. All of my docker-compose files are tracked in Git.&lt;/p&gt;
&lt;p&gt;Except for Headscale and Nextcloud HPB - which need direct, unproxied web access or ports open to the world - they all sit in VMs managed by Proxmox, on an HP EliteDesk 800 G3 in my cupboard.&lt;/p&gt;
&lt;p&gt;They all &lt;a href="https://blog.thms.uk/2026/06/oracle-caddy-tailscale-proxy/"&gt;proxy through an Oracle Cloud free-tier server&lt;/a&gt; that hosts the Headscale control node, Nextcloud HPB, and my static sites.&lt;/p&gt;
&lt;h2 id="static-sites"&gt;Static sites&lt;/h2&gt;
&lt;p&gt;I host a number of static sites for myself. I&amp;rsquo;ve written elsewhere about how I host and deploy these - &lt;a href="https://blog.thms.uk/2026/06/self-hosted-blog-forgejo/"&gt;Forgejo Actions copy the files to a remote Headscale node&lt;/a&gt;. These currently include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://thms.uk"&gt;thms.uk&lt;/a&gt;: A directory page for my domain.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://michael.thms.uk"&gt;michael.thms.uk&lt;/a&gt;: A profile page for me.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.thms.uk"&gt;blog.thms.uk&lt;/a&gt;: This blog.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://fedialgo.thms.uk"&gt;fedialgo.thms.uk&lt;/a&gt;: An algorithmic timeline client for Mastodon.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="infrastructure"&gt;Infrastructure&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/juanfont/headscale"&gt;Headscale&lt;/a&gt;: An open source, self-hosted implementation of the Tailscale control server. Yes, I could use Tailscale, but where would be the fun in that?&lt;/li&gt;
&lt;li&gt;&lt;a href="https://headplane.net"&gt;Headplane&lt;/a&gt;: An admin interface for Headscale.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pocket-id.org"&gt;Pocket ID&lt;/a&gt;: An OAuth 2 and OpenID Connect provider. I use this primarily for authenticating to Headscale and Headplane, but also for Forgejo, Proxmox, OCI, and a few other services.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://crazymax.dev/diun/"&gt;diun&lt;/a&gt;: Docker Image Update Notifier. It notifies me whenever a new Docker image is available for any of my services.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pi-hole.net"&gt;Pi-hole&lt;/a&gt;: A DNS server that blocks ads and malicious websites. I use it primarily for resolving DNS locally within my homelab.&lt;/li&gt;
&lt;li&gt;Caddy for proxying.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="monitoring"&gt;Monitoring&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://grafana.com"&gt;Grafana&lt;/a&gt;: A monitoring dashboard to keep track of the health of all my Docker services and VMs.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/louislam/uptime-kuma"&gt;Uptime Kuma&lt;/a&gt;: A simple, attractive uptime monitor that I use for all my web services and important cron jobs.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://status.thms.uk"&gt;status.thms.uk&lt;/a&gt;: A simple public status page for all my services. Powered by Uptime Kuma.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/plausible/community-edition"&gt;Plausible Community Edition&lt;/a&gt;: An analytics service to see how people read my blog and other sites so I can feel important (or unimportant?).&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="coding"&gt;Coding&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://code.thms.uk"&gt;code.thms.uk&lt;/a&gt;: A Forgejo instance for my code. I also host some CI/CD runners with it, including the ones that deploy the static sites mentioned above.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://paste.thms.uk"&gt;paste.thms.uk&lt;/a&gt;: An Opengist instance to host my pastes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="nextcloud-helpers"&gt;Nextcloud helpers&lt;/h2&gt;
&lt;p&gt;Whilst I&amp;rsquo;m using Hetzner&amp;rsquo;s Storage Share for Nextcloud, I&amp;rsquo;ve set up a couple of &amp;lsquo;helpers&amp;rsquo; in improve the experience of using Nextcloud:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.collaboraonline.com/code/"&gt;Collabora CODE&lt;/a&gt;: Lets me edit documents from my Nextcloud instance in the browser.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://help.nextcloud.com/t/high-performance-backend-for-talk-on-nextcloud-with-docker/215828"&gt;Nextcloud HPB&lt;/a&gt;: The high-performance backend for Nextcloud Talk - needed to improve the performance of Talk, which we use for video calls etc.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="reading-and-social"&gt;Reading and Social&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://mstdn.thms.uk"&gt;mstdn.thms.uk&lt;/a&gt;: My self-hosted Mastodon instance.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://read.thms.uk"&gt;read.thms.uk&lt;/a&gt;: A FreshRSS instance for reading my RSS feeds.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/pictuga/morss"&gt;morss&lt;/a&gt;: A tool to convert non-full-text RSS feeds to full-text ones. The project is largely abandoned, and most of its functionality can now be replicated within FreshRSS itself, but a couple of my feeds still work better with morss in the middle, for whatever reason.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="others"&gt;Others&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/pglombardo/passwordpusher"&gt;Password Pusher&lt;/a&gt;: A really great tool to securely exchange passwords and other secrets.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.home-assistant.io"&gt;Home Assistant&lt;/a&gt; for home automation.&lt;/li&gt;
&lt;li&gt;A small number of sites and apps I&amp;rsquo;ve written myself, for myself or friends, hosted as Docker containers on my homelab.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-im-not-yet-self-hosting"&gt;What I&amp;rsquo;m not (yet) self-hosting&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://nextcloud.com"&gt;Nextcloud&lt;/a&gt;: I&amp;rsquo;m currently using Hetzner&amp;rsquo;s Storage Share instead of self-hosting. It&amp;rsquo;s good value for money, and given the importance of the data - and how upset my family would be about any outages - it&amp;rsquo;s the best option for now. I&amp;rsquo;m hoping to migrate to self-hosting Nextcloud eventually, but I think I&amp;rsquo;ll need better hardware for that first, including a proper RAID setup.&lt;/li&gt;
&lt;li&gt;Email: I currently use Fastmail for my own and my family&amp;rsquo;s email, on our own custom domains. I really like the Fastmail experience, and I fear the complexity of self-hosting email - and getting deliverability right - far too much to make the move.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="wrapping-up"&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;If you self-host something I&amp;rsquo;ve not mentioned here and think I&amp;rsquo;m missing out, I&amp;rsquo;d love to hear about it in the comments/replies - especially anything that&amp;rsquo;s replaced a paid service you were glad to see the back of.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116896507051990499"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Self-hosting a static site using Forgejo, Headscale, and Caddy</title><link>https://blog.thms.uk/2026/06/self-hosted-blog-forgejo/</link><guid>https://blog.thms.uk/2026/06/self-hosted-blog-forgejo/</guid><pubDate>Tue, 30 Jun 2026 06:00:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/meta/">meta</category><category domain="https://blog.thms.uk/tag/forgejo/">forgejo</category><category domain="https://blog.thms.uk/tag/tools/">tools</category><category domain="https://blog.thms.uk/tag/selfhosting/">selfhosting</category><category domain="https://blog.thms.uk/tag/homelab/">homelab</category><description>Self-hosting a static blog with push-to-deploy: Forgejo Actions ships the site over a Tailscale/Headscale tailnet to a Caddy reverse proxy, with no long-lived SSH keys to manage.</description><enclosure url="https://media.thms.uk/images/2026-06-29-moving-blog-to-local.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="self-hosting-a-static-site-using-forgejo-headscale-and-caddy"&gt;Self-hosting a static site using Forgejo, Headscale, and Caddy&lt;/h1&gt;
&lt;p&gt;When I first started this blog I was on the &lt;a href="https://blog.thms.uk/2023/03/blogging-with-webloglol-and-github-actions/"&gt;omg.lol platform&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This was a great way for me to get started, and I think without it, I probably would&amp;rsquo;ve never started my blog, so I&amp;rsquo;m really grateful for omg.lol!&lt;/p&gt;
&lt;p&gt;But over the last few months I&amp;rsquo;ve gradually moved more and more of my stuff to self-hosted, and with omg.lol more than doubling their price (which, to be fair, is still excellent value!) it was time to move my blog to self-hosted too.&lt;/p&gt;
&lt;p&gt;This post explains how I now host my static blog at &lt;code&gt;blog.thms.uk&lt;/code&gt;, deployed straight from a Forgejo repo via Forgejo Actions, over a Tailscale/Headscale tailnet, onto a Caddy reverse proxy, auto-deploying when I push to &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="set-up-the-host-machine"&gt;Set up the host machine&lt;/h2&gt;
&lt;p&gt;First job is to create a home for the site and a user to own it. The site will be hosted on my existing server that already has Caddy installed. We&amp;rsquo;ll create a &lt;code&gt;deploy&lt;/code&gt; user that owns &lt;code&gt;/var/www&lt;/code&gt; so the rsync at the end has somewhere to write to. On the web server, create the user and directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo useradd -m deploy&lt;br&gt;sudo mkdir /var/www&lt;br&gt;sudo chown deploy:deploy /var/www&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="set-up-headscale"&gt;Set up Headscale&lt;/h2&gt;
&lt;p&gt;The plan here is to let the CI runner reach the web server over the tailnet and nothing else. We do that with tags: the runner joins as &lt;code&gt;tag:ci&lt;/code&gt; and the web server is &lt;code&gt;tag:web&lt;/code&gt;. No keys, no exposed ports on the public internet.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m running my tailnet through a self-hosted Headscale instance via Docker Compose, so that&amp;rsquo;s what the rest of this section focuses on. If you&amp;rsquo;re using Tailscale you&amp;rsquo;ll have to do essentially the same steps in the Tailscale dashboard.&lt;/p&gt;
&lt;p&gt;First, define the tags and permissions in the policy file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;{&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;tagOwners&amp;#34;: {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;tag:ci&amp;#34;: [&amp;#34;&amp;lt;your-user&amp;gt;&amp;#34;],&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;tag:web&amp;#34;: [&amp;#34;&amp;lt;your-user&amp;gt;&amp;#34;]&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;},&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;acls&amp;#34;: [&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{ &amp;#34;action&amp;#34;: &amp;#34;accept&amp;#34;, &amp;#34;src&amp;#34;: [&amp;#34;tag:ci&amp;#34;], &amp;#34;dst&amp;#34;: [&amp;#34;tag:web:22&amp;#34;] }&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;],&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;ssh&amp;#34;: [&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{ &amp;#34;action&amp;#34;: &amp;#34;accept&amp;#34;, &amp;#34;src&amp;#34;: [&amp;#34;tag:ci&amp;#34;], &amp;#34;dst&amp;#34;: [&amp;#34;tag:web&amp;#34;], &amp;#34;users&amp;#34;: [&amp;#34;deploy&amp;#34;] }&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;]&lt;br&gt;}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You need both &lt;code&gt;acl&lt;/code&gt; and &lt;code&gt;ssh&lt;/code&gt; rules because they work at different layers: the &lt;code&gt;acls&lt;/code&gt; entry is the network firewall, while the &lt;code&gt;ssh&lt;/code&gt; entry is what then authorises the actual SSH session.&lt;/p&gt;
&lt;p&gt;Then restart headscale to apply the change:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d --force-recreate headscale&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Now, assign the &lt;code&gt;tag:web&lt;/code&gt; tag to the web server (&lt;code&gt;&amp;lt;id&amp;gt;&lt;/code&gt; is the ID of the web server - you can find it with &lt;code&gt;docker exec headscale headscale nodes list&lt;/code&gt;):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec headscale headscale nodes tag -i &amp;lt;id&amp;gt; -t tag:web&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Finally, we need a pre-auth key for the runner to authenticate with. We&amp;rsquo;ll create an ephemeral key so the node disappears from the tailnet when the job finishes (no pile-up of dead CI nodes), and mark it reusable so every run can use the same secret. We also add a long expiry to save us rotating it constantly - 876,000 hours is roughly 100 years, so dial that back if it makes you nervous:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec headscale headscale preauthkeys create --ephemeral --reusable --tags tag:ci -e 876000h&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This key will allow the deployer CI to join the tailnet as a machine tagged with &lt;code&gt;tag:ci&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Finally, if it isn&amp;rsquo;t already on, enable Tailscale SSH on the web server:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tailscale set --ssh&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="create-your-deployment-script"&gt;Create your deployment script&lt;/h2&gt;
&lt;p&gt;With the tailnet sorted, it&amp;rsquo;s finally time to move on to the most important part of this post: the deploy workflow. We&amp;rsquo;ll create it in &lt;code&gt;.forgejo/workflows/deploy.yml&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Deploying the site, for me, means:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check out the repo.&lt;/li&gt;
&lt;li&gt;Set up Hugo, as that&amp;rsquo;s what my blog is built with.&lt;/li&gt;
&lt;li&gt;Build the site using &lt;code&gt;hugo --minify&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Join the tailnet using Tailscale&amp;rsquo;s official GitHub Action.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;rsync&lt;/code&gt; the site to the web server node.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Obviously steps 2 and 3 may well be different for your site, so adjust them accordingly.&lt;/p&gt;
&lt;p&gt;It&amp;rsquo;s worth noting that step 3 produces the final static files for the blog in &lt;code&gt;./public&lt;/code&gt;, and that&amp;rsquo;s what we&amp;rsquo;ll be &lt;code&gt;rsync&lt;/code&gt;ing in the final step. If your build step produces its files in a different location (or you aren&amp;rsquo;t using any build steps at all) you&amp;rsquo;ll need to change the &lt;code&gt;rsync&lt;/code&gt; line at the end.&lt;/p&gt;
&lt;p&gt;The comments in the file spell out each part, along with some reasoning behind each step. (If you&amp;rsquo;d rather not copy this by hand, I&amp;rsquo;ve since packaged it as a reusable action - see the &lt;a href="#post-scriptum"&gt;postscript&lt;/a&gt; below.)&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;on:&lt;br&gt;&amp;nbsp;&amp;nbsp;workflow_dispatch:&lt;br&gt;&amp;nbsp;&amp;nbsp;push:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;branches:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- main&lt;br&gt;&lt;br&gt;jobs:&lt;br&gt;&amp;nbsp;&amp;nbsp;build:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;runs-on: ubuntu-latest # replace with whatever runner you have available.&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;name: Deploy site&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;steps:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# 1. Check out the repo.&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- uses: https://github.com/actions/checkout@v4&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;with:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;fetch-depth: 0&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# 2. Set up Hugo&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- name: Setup Hugo (extended)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;uses: https://github.com/peaceiris/actions-hugo@v3&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;with:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;hugo-version: latest&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;extended: true&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# 3. Build the site&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- name: Build site&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;run: hugo --minify&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shell: bash&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# 4. Join the tailnet (Headscale) as an ephemeral, tagged node (tag:ci) via the&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# official action. authkey + login-server points it at Headscale; userspace&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# networking + a local SOCKS5 proxy means no TUN/privileges in the container.&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- name: Connect to Tailnet&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;uses: https://github.com/tailscale/github-action@v3&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;with:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;authkey: ${{ secrets.TS_AUTHKEY }} # Headscale pre-auth key (reusable, ephemeral)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;tailscaled-args: --tun=userspace-networking --socks5-server=localhost:1055&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;args: --login-server=${{ secrets.TS_LOGIN_SERVER }} # Drop this if you are using Tailscale rather than Headscale.&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# 5. Publish ./public to the remote node over Tailscale SSH. ssh/rsync tunnel through&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# the SOCKS5 proxy the step above started.&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- name: Publish to Caddy&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;env:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;SITE: blog.thms.uk # replace with your site name&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} # remote node tailnet IP (100.x.y.z)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;DEPLOY_USER: deploy # Unix user on the node (Tailscale SSH login)&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;run: |&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;set -e&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# Ensure we have all the tools we need: rsync, OpenSSH client, netcat for the SOCKS proxy&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;pkgs= &lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;command -v rsync &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 || pkgs=&amp;#34;$pkgs rsync&amp;#34; &lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;command -v ssh &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 || pkgs=&amp;#34;$pkgs openssh-client&amp;#34; &lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;command -v nc &amp;gt;/dev/null 2&amp;gt;&amp;amp;1 || pkgs=&amp;#34;$pkgs netcat-openbsd&amp;#34; &lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[ -n &amp;#34;$pkgs&amp;#34; ] &amp;amp;&amp;amp; { apt-get update -qq &amp;amp;&amp;amp; apt-get install -y -qq $pkgs; }&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# Set up `~/.ssh/config` to configure the SSH tunnel and other SSH client settings&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;install -d -m 700 ~/.ssh&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;printf &amp;#39;Host deploy-target\n HostName %s\n User %s\n ProxyCommand nc -X 5 -x localhost:1055 %%h %%p\n StrictHostKeyChecking accept-new\n ConnectTimeout 20\n BatchMode yes\n&amp;#39; \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;#34;$DEPLOY_HOST&amp;#34; &amp;#34;$DEPLOY_USER&amp;#34; &amp;gt; ~/.ssh/config&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;chmod 600 ~/.ssh/config&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# create the required directory on the remote node&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ssh deploy-target &amp;#34;mkdir -p /var/www/$SITE&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# rsync the site to the remote node&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rsync -az --delete -e ssh public/ &amp;#34;deploy-target:/var/www/$SITE/&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;shell: bash&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The script requires three secrets, so add the following Action Secrets to the repository:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;TS_AUTHKEY&lt;/code&gt; - the pre-auth key for the Tailscale node, which you created above.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;TS_LOGIN_SERVER&lt;/code&gt; - the Headscale login server (which you can omit if you are using Tailscale).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;DEPLOY_HOST&lt;/code&gt; - the tailnet IP address of the node you are deploying to.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="configure-caddy"&gt;Configure Caddy&lt;/h2&gt;
&lt;p&gt;The final step is getting Caddy to actually serve the files. I have Caddy running in a container, so it can&amp;rsquo;t see &lt;code&gt;/var/www&lt;/code&gt; on the host until we mount it in - which we&amp;rsquo;ll do read-only, since Caddy only ever needs to read the site. Add the bind mount to the Caddy service:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:&lt;br&gt;&amp;nbsp;&amp;nbsp;caddy:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# [...]&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;volumes:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- /var/www:/var/www:ro&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;# other bind mounts&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then tell Caddy about the site. This is the whole config for a static site - point &lt;code&gt;root&lt;/code&gt; at the deploy directory, turn on compression, and hand it to the file server:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;blog.thms.uk {&lt;br&gt;&amp;nbsp;&amp;nbsp;root * /var/www/blog.thms.uk&lt;br&gt;&amp;nbsp;&amp;nbsp;encode zstd gzip&lt;br&gt;&amp;nbsp;&amp;nbsp;file_server&lt;br&gt;&amp;nbsp;&amp;nbsp;&lt;br&gt;&amp;nbsp;&amp;nbsp;# This part is for pretty 404 pages, and assumes you have a 404 page at /404.html&lt;br&gt;&amp;nbsp;&amp;nbsp;handle_errors {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;@404 expression {http.error.status_code} == 404&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rewrite @404 /404.html&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;file_server&lt;br&gt;&amp;nbsp;&amp;nbsp;}&lt;br&gt;}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The bind mount is a change to the container itself, so this one time you do need to recreate it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d --force-recreate caddy&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After that, adding new sites is much cheaper: The volume&amp;rsquo;s already mounted, so it&amp;rsquo;s just another block in the Caddyfile followed by a config reload - no restart, no dropped connections:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec caddy caddy reload --config /etc/caddy/Caddyfile&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="wrapping-up"&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;That&amp;rsquo;s the lot. Push to &lt;code&gt;main&lt;/code&gt; and the site builds, joins the tailnet as an ephemeral tagged node, rsyncs &lt;code&gt;./public&lt;/code&gt; to the remote over Tailscale SSH, and Caddy serves it. Adding more sites later is just another block in the Caddyfile and a config reload.&lt;/p&gt;
&lt;h2 id="post-scriptum"&gt;Post scriptum&lt;/h2&gt;
&lt;p&gt;I&amp;rsquo;ve since extracted this deployment script into a reusable Forgejo Action, so you can drop it into your own projects without copying all the above by hand. You can find it at &lt;a href="https://code.thms.uk/michael/tailscale-deploy"&gt;code.thms.uk/michael/tailscale-deploy&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116837564627898724"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Using a Tailscale exit node on a Docker host</title><link>https://blog.thms.uk/2026/06/docker-tailscale-exit-node/</link><guid>https://blog.thms.uk/2026/06/docker-tailscale-exit-node/</guid><pubDate>Thu, 18 Jun 2026 06:00:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/mastodon/">mastodon</category><category domain="https://blog.thms.uk/tag/docker/">docker</category><category domain="https://blog.thms.uk/tag/tailscale/">tailscale</category><category domain="https://blog.thms.uk/tag/selfhosting/">selfhosting</category><category domain="https://blog.thms.uk/tag/homelab/">homelab</category><description>How to stop a Tailscale exit node from breaking Docker container networking, using Linux policy routing and a small systemd unit to make the fix permanent.</description><enclosure url="https://media.thms.uk/images/2026-06-docker-tailscale-exit-node.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="using-a-tailscale-exit-node-on-a-docker-host"&gt;Using a Tailscale exit node on a Docker host&lt;/h1&gt;
&lt;p&gt;I&amp;rsquo;ve recently &lt;a href="https://blog.thms.uk/2026/06/moving-mastodon-to-docker"&gt;migrated my Mastodon instance to run on Docker Compose&lt;/a&gt;. This host sits on my home network, and gets exposed to the internet using &lt;a href="https://blog.thms.uk/2026/06/oracle-caddy-tailscale-proxy"&gt;Caddy on a VPS that&amp;rsquo;s connected to my server using Tailscale&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;This works great, but I did want to route outgoing traffic through the same VPS, so that other server operators don&amp;rsquo;t see my domestic IP address in their server logs.&lt;/p&gt;
&lt;p&gt;This should be easy enough:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;On the edge server add the exit node flag:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;sudo tailscale set --advertise-exit-node&lt;/code&gt;&lt;/pre&gt;&lt;ol start="2"&gt;
&lt;li&gt;Configure the Mastodon host to use the exit node:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;sudo tailscale set --exit-node=&amp;lt;exit-node-ip&amp;gt; --exit-node-allow-lan-access=true&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Easy as pie (yes, you also need to configure IP forwarding, may need to approve it in the admin UI, and set a policy to allow access - still easy as pie).&lt;/p&gt;
&lt;p&gt;I did think it worked, as I ran&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl https://ip.me&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and got my edge IP address.&lt;/p&gt;
&lt;p&gt;But then I tested it from within the container:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec mastodon-sidekiq-1 curl https://ip.me&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and the whole thing just hung there.&lt;/p&gt;
&lt;h2 id="the-problem"&gt;The problem&lt;/h2&gt;
&lt;p&gt;Basically, the Docker host sits as a NAT device in front of the Docker containers. And when you enable the Tailscale exit node, that intercepts traffic on all interfaces and routes it via Tailscale - including traffic destined for your container subnet. So nothing can reach your Docker containers any longer, and connections just hang.&lt;/p&gt;
&lt;h2 id="the-solution"&gt;The solution&lt;/h2&gt;
&lt;p&gt;Firstly, let&amp;rsquo;s see what IP address we need to route to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker inspect mastodon-web-1&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In the output look for &lt;code&gt;IPAddress&lt;/code&gt; and make a note (in my case it was &lt;code&gt;172.19.0.2&lt;/code&gt;).&lt;/p&gt;
&lt;p&gt;Now, check the route that your system has for that IP address:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ip route get 172.19.0.2&lt;br&gt;172.19.0.2 dev tailscale0 table 52 src 100.64.0.8 uid 1000&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cache&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This gives us two insights:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Traffic for that IP address is indeed routed towards &lt;code&gt;tailscale0&lt;/code&gt;, so our initial theory was correct.&lt;/li&gt;
&lt;li&gt;The thing routing it is &lt;code&gt;table 52&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So we need to add our own route to that IP address. And we need to do so with a priority that comes before (i.e. is lower than) the rule for table 52. So let&amp;rsquo;s look at our existing rules:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ip rule show&lt;br&gt;0: from all lookup local&lt;br&gt;5210: from all fwmark 0x80000/0xff0000 lookup main&lt;br&gt;5230: from all fwmark 0x80000/0xff0000 lookup default&lt;br&gt;5250: from all fwmark 0x80000/0xff0000 unreachable&lt;br&gt;5270: from all lookup 52&lt;br&gt;32766: from all lookup main&lt;br&gt;32767: from all lookup default&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Okay, so the rule for table 52 has a priority of 5270.&lt;/p&gt;
&lt;p&gt;Let&amp;rsquo;s add a new rule with a lower priority of 5269 (replace with a different value if your rules have different priorities, and replace your subnet, if your Docker is configured differently):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;ip rule add to 172.16.0.0/12 lookup main priority 5269&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This means we are routing all traffic to &lt;code&gt;172.16.0.0/12&lt;/code&gt; (which covers Docker&amp;rsquo;s default subnets) outside of Tailscale, and we are doing this with priority 5269 - immediately before Tailscale&amp;rsquo;s rule. Let&amp;rsquo;s check the route now:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ ip route get 172.19.0.2&lt;br&gt;172.19.0.2 dev br-29ab31f9f5aa src 172.19.0.1 uid 1000&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;cache&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;indeed, this now routes to my Docker bridge device, and we can confirm by running &lt;code&gt;docker exec mastodon-web-1 curl https://ip.me&lt;/code&gt; which should now return your exit node&amp;rsquo;s IP address rather than your own.&lt;/p&gt;
&lt;h2 id="making-the-solution-permanent"&gt;Making the solution permanent&lt;/h2&gt;
&lt;p&gt;I think the best solution is a quick service file that inserts the rule when Tailscale and Docker start up:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;[Unit]&lt;br&gt;Description=Route Docker subnets via main table so exit-node return traffic reaches containers&lt;br&gt;After=network-online.target tailscaled.service docker.service&lt;br&gt;Wants=network-online.target&lt;br&gt;&lt;br&gt;[Service]&lt;br&gt;Type=oneshot&lt;br&gt;RemainAfterExit=yes&lt;br&gt;# delete then add keeps it idempotent if the unit is re-run&lt;br&gt;# replace your subnets and/or priority as needed&lt;br&gt;ExecStartPre=-/sbin/ip rule del to 172.16.0.0/12 lookup main priority 5269&lt;br&gt;ExecStart=/sbin/ip rule add to 172.16.0.0/12 lookup main priority 5269&lt;br&gt;ExecStop=-/sbin/ip rule del to 172.16.0.0/12 lookup main priority 5269&lt;br&gt;&lt;br&gt;[Install]&lt;br&gt;WantedBy=multi-user.target&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Finally, enable the service:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl daemon-reload&lt;br&gt;sudo systemctl enable --now docker-tailscale-routing.service&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="wrapping-up"&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;A bit of policy routing was all it took: one &lt;code&gt;ip rule&lt;/code&gt; to keep Docker&amp;rsquo;s subnets out of Tailscale&amp;rsquo;s hands, made permanent with a small service file. My containers now reach the internet through the exit node like everything else, and my domestic IP stays out of other people&amp;rsquo;s logs.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116770609483782422"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Moving Mastodon to Docker Compose</title><link>https://blog.thms.uk/2026/06/moving-mastodon-to-docker/</link><guid>https://blog.thms.uk/2026/06/moving-mastodon-to-docker/</guid><pubDate>Mon, 15 Jun 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/mastodon/">mastodon</category><category domain="https://blog.thms.uk/tag/docker/">docker</category><category domain="https://blog.thms.uk/tag/selfhosting/">selfhosting</category><category domain="https://blog.thms.uk/tag/homelab/">homelab</category><description>I finally moved my Mastodon server to Docker. Here's the full migration: inventorying dependency versions, keeping config in git, and importing the postgres dump and redis snapshot.</description><enclosure url="https://media.thms.uk/images/2026-06-migrating-mastodon.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="moving-mastodon-to-docker-compose"&gt;Moving Mastodon to Docker Compose&lt;/h1&gt;
&lt;p&gt;Now that I &lt;a href="https://blog.thms.uk/2026/06/laravel-frankenphp-dockerfile"&gt;understand docker a lot better&lt;/a&gt;, I figured it&amp;rsquo;s finally time to tackle the migration of my &lt;a href="https://blog.thms.uk/2023/01/setting-up-mastodon"&gt;mastodon server&lt;/a&gt; to docker.&lt;/p&gt;
&lt;p&gt;This post outlines how I&amp;rsquo;ve done the migration. Turns out it&amp;rsquo;s really quite straightforward!&lt;/p&gt;
&lt;h2 id="inventory-current-setup"&gt;Inventory current setup&lt;/h2&gt;
&lt;p&gt;Firstly, we take stock of what we have: take a note of all the versions of our software dependencies, so we can point our docker compose files at the same versions. You can check all of these at the bottom of &lt;code&gt;/admin/dashboard&lt;/code&gt;. For me these were:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mastodon Version&lt;/strong&gt;: glitch-soc, v4.6.0-rc.1&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Postgres Version&lt;/strong&gt;: 17.9&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ES Version&lt;/strong&gt;: 7.17.29&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Redis Version&lt;/strong&gt;: 7.0.15&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="get-docker-compose-file"&gt;Get docker compose file&lt;/h2&gt;
&lt;p&gt;Now let&amp;rsquo;s get the docker compose file from the official source. (I run glitch-soc, so I got it from &lt;a href="https://github.com/glitch-soc/mastodon/blob/main/docker-compose.yml"&gt;https://github.com/glitch-soc/mastodon/blob/main/docker-compose.yml&lt;/a&gt;. If you run stock mastodon get yours from &lt;a href="https://github.com/mastodon/mastodon/blob/main/docker-compose.yml"&gt;https://github.com/mastodon/mastodon/blob/main/docker-compose.yml&lt;/a&gt;.)&lt;/p&gt;
&lt;p&gt;Then make some changes:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Un-comment the &lt;code&gt;es&lt;/code&gt; service.&lt;/li&gt;
&lt;li&gt;Point &lt;code&gt;es&lt;/code&gt;, &lt;code&gt;db&lt;/code&gt;, &lt;code&gt;redis&lt;/code&gt;, &lt;code&gt;web&lt;/code&gt;, &lt;code&gt;streaming&lt;/code&gt; and &lt;code&gt;sidekiq&lt;/code&gt; to the correct versions.&lt;/li&gt;
&lt;li&gt;In the &lt;code&gt;web&lt;/code&gt; service, un-comment &lt;code&gt;depends_on: es&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="migrate-config"&gt;Migrate config&lt;/h2&gt;
&lt;p&gt;One thing that&amp;rsquo;s probably a bit controversial, but I do like to keep as much of my config as possible in the &lt;code&gt;docker-compose.yml&lt;/code&gt; file. This allows me to add the file to git so I can version control it, and quickly see whatever might change.&lt;/p&gt;
&lt;p&gt;In order to do this I&amp;rsquo;ll add a shared yaml anchor, with all the non-secret environment variables, and then reference this within each of the mastodon services:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;x-mastodon-env: &amp;amp;mastodon-env&lt;br&gt;&amp;nbsp;&amp;nbsp;LOCAL_DOMAIN: thms.uk&lt;br&gt;&amp;nbsp;&amp;nbsp;WEB_DOMAIN: mstdn.thms.uk&lt;br&gt;&amp;nbsp;&amp;nbsp;[...]&lt;br&gt;&lt;br&gt;services:&lt;br&gt;&amp;nbsp;&amp;nbsp;web:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;image: ghcr.io/glitch-soc/mastodon:v4.6.0-rc.1&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;restart: always&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;env_file: .env.production # secrets only&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;environment:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;&amp;lt;: *mastodon-env&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[..]&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;streaming:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;image: ghcr.io/glitch-soc/mastodon-streaming:v4.6.0-rc.1&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;restart: always&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;env_file: .env.production # secrets only&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;environment:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;&amp;lt;: *mastodon-env&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[..]&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;sidekiq:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;image: ghcr.io/glitch-soc/mastodon:v4.6.0-rc.1&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;restart: always&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;env_file: .env.production # secrets only&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;environment:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;&amp;lt;: *mastodon-env&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[..]&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;My &lt;code&gt;.env.production&lt;/code&gt; file is now really short:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;SECRET_KEY_BASE=
OTP_SECRET=
VAPID_PRIVATE_KEY=
AWS_SECRET_ACCESS_KEY=
SMTP_PASSWORD=
DEEPL_API_KEY=
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY=
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT=
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY=
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;All of these values are simply copied from my production &lt;code&gt;.env.production&lt;/code&gt; file.&lt;/p&gt;
&lt;h2 id="pull-images-so-they-are-ready"&gt;Pull images so they are ready&lt;/h2&gt;
&lt;p&gt;We&amp;rsquo;ll pull the images, so they are ready to use when we want to migrate data. This just shortens our downtime slightly:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose pull&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="migrate-data"&gt;Migrate data&lt;/h2&gt;
&lt;p&gt;Migrating the data is the same as it&amp;rsquo;s always been really. I host my media on Backblaze B2, so I don&amp;rsquo;t need to worry about this.&lt;/p&gt;
&lt;p&gt;I also won&amp;rsquo;t bother migrating the elasticsearch index: this can simply be recreated afterwards.&lt;/p&gt;
&lt;p&gt;As such, I need to migrate my SQL and redis databases.&lt;/p&gt;
&lt;p&gt;We&amp;rsquo;ll firstly shut down the old system so we get a consistent state:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo systemctl stop mastodon-*&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then we make a postgres dump:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo -u mastodon pg_dump -Fc -p 5433 -d mastodon_production &amp;gt; mastodon.dump&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And make sure we have an up-to-date redis snapshot:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;redis-cli SAVE&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;On the new docker host we&amp;rsquo;ll now copy both files across:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;scp old-vm:~/mastodon.dump ~/mastodon.dump&lt;br&gt;mkdir ./redis&lt;br&gt;scp old-vm:/var/lib/redis/dump.rdb ./redis/dump.rdb&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;We need to adjust permissions on the redis directory:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo chown -R 999:999 ./redis&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And bring the redis service up:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d redis&lt;br&gt;docker compose logs redis&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In the logs look for &amp;ldquo;DB loaded from disk&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s redis ready. To import the postgres DB, firstly bring up the &lt;code&gt;db&lt;/code&gt; service, and create the user and database:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d db&lt;br&gt;docker compose exec -T db psql -U postgres -c &amp;#34;CREATE ROLE mastodon LOGIN CREATEDB;&amp;#34;&lt;br&gt;docker compose exec -T db psql -U postgres -c &amp;#34;CREATE DATABASE mastodon_production OWNER mastodon;&amp;#34;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then run the &lt;code&gt;pg_restore&lt;/code&gt; command:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose exec -T db pg_restore -U mastodon -d mastodon_production --no-owner &amp;lt; ~/mastodon.dump&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="bring-up-new-server"&gt;Bring up new server&lt;/h2&gt;
&lt;p&gt;Now that this is all done, we just bring up the remaining services:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And finally we re-deploy the search index:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker compose run --rm web nice -n 19 bin/tootctl search deploy&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="wrapping-up"&gt;Wrapping up&lt;/h2&gt;
&lt;p&gt;That&amp;rsquo;s the migration done: mastodon is now running entirely on docker, with the bulk of my config version-controlled in a single &lt;code&gt;docker-compose.yml&lt;/code&gt;. Before tearing down the old VM, give everything a once-over: check the web UI loads, federation is working, and the search index has finished deploying. Once you&amp;rsquo;re happy, the old systemd setup can go.&lt;/p&gt;
&lt;h2 id="postscript"&gt;Postscript&lt;/h2&gt;
&lt;p&gt;I have since published the git repo which I use to track my docker compose files on Forgejo: &lt;a href="https://code.thms.uk/michael/mastodon-compose"&gt;https://code.thms.uk/michael/mastodon-compose&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116753727750319747"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>A multi-stage Dockerfile for Laravel and FrankenPHP</title><link>https://blog.thms.uk/2026/06/laravel-frankenphp-dockerfile/</link><guid>https://blog.thms.uk/2026/06/laravel-frankenphp-dockerfile/</guid><pubDate>Thu, 11 Jun 2026 06:30:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/php/">php</category><category domain="https://blog.thms.uk/tag/tools/">tools</category><category domain="https://blog.thms.uk/tag/laravel/">laravel</category><category domain="https://blog.thms.uk/tag/docker/">docker</category><description>A walkthrough of a four-stage Dockerfile that ships a lean Laravel 13 image on FrankenPHP, plus the entrypoint and Compose services to actually run it.</description><enclosure url="https://media.thms.uk/images/2026-06-laravel-frankenphp-dockerfile.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="a-multi-stage-dockerfile-for-laravel-and-frankenphp"&gt;A multi-stage Dockerfile for Laravel and FrankenPHP&lt;/h1&gt;
&lt;p&gt;I recently rewrote an old website that I originally wrote 15+ years ago. Whilst I&amp;rsquo;m still running the site as a favour to old friends, I had sorely neglected it, making bug fixes incredibly difficult: It was some framework-less hot mess running on PHP 7.4, which I was really struggling to maintain.&lt;/p&gt;
&lt;p&gt;So I decided to rewrite it in PHP 8.5 using Laravel 13, and to run it on my home lab where everything else I run lives in docker containers managed by docker compose.&lt;/p&gt;
&lt;p&gt;So I had to learn to write my first Dockerfile. I mostly used Claude to write it for me, and then tried to figure out what each step does and why.&lt;/p&gt;
&lt;p&gt;This post outlines what I learned, mostly as a note to self, so my learnings don&amp;rsquo;t get lost to time.&lt;/p&gt;
&lt;h2 id="starting-point-what-do-i-need"&gt;Starting point: What do I need?&lt;/h2&gt;
&lt;p&gt;I have so far been a consumer of docker only: I would get a &lt;code&gt;docker-compose.yml&lt;/code&gt;, adjust it marginally to my need with some trial and error, and run it. So I first had to figure out what I need:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Dockerfile&lt;/strong&gt; The Dockerfile tells docker how to build the image: Which files and software to include, in what order, etc.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Entrypoint&lt;/strong&gt; The entrypoint is a (bash) script that runs after the image has been started, and as such is responsible for actually starting the app.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;docker-compose.yml&lt;/code&gt;&lt;/strong&gt; These I had seen many times before. Basically defines how the image(s) fit together, what ports they expose their service(s) on, what directories from the host they have access to etc.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="architecture"&gt;Architecture&lt;/h2&gt;
&lt;p&gt;For my Laravel 13 project I need the following in my architecture:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A web server and PHP runtime. I decided on Caddy and FrankenPHP, as that&amp;rsquo;s the way the cool kids run these things now. Conveniently, FrankenPHP provides &lt;a href="https://frankenphp.dev/docs/docker/"&gt;docker images that can be used as a base too&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;A queue worker: Based on the same image, but running as a separate service.&lt;/li&gt;
&lt;li&gt;A cron worker: Same story.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;One thing I learned in the process, but hadn&amp;rsquo;t planned out, is that I&amp;rsquo;d also want an init service that runs migrations and optimisations once at startup before the other services start.&lt;/p&gt;
&lt;h2 id="the-dockerfile"&gt;The Dockerfile&lt;/h2&gt;
&lt;p&gt;With all of that out of the way, let&amp;rsquo;s start writing the Dockerfile.&lt;/p&gt;
&lt;p&gt;Fundamentally, we are building the image in four discrete steps. Each step defines a slightly different image, and the final image, which we&amp;rsquo;ll push to production, only keeps what it needs from the others.&lt;/p&gt;
&lt;p&gt;This keeps our runtime image really as small as possible, and avoids the inclusion of build tools such as &lt;code&gt;git&lt;/code&gt; and &lt;code&gt;npm&lt;/code&gt; in the final build.&lt;/p&gt;
&lt;p&gt;We&amp;rsquo;ll place this file in our git repo&amp;rsquo;s root directory, and call it &lt;code&gt;Dockerfile&lt;/code&gt; (no file ending).&lt;/p&gt;
&lt;h3 id="stage-1-the-base-build"&gt;Stage 1: The base build&lt;/h3&gt;
&lt;p&gt;This is really the shared foundation that everything builds on:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# syntax=docker/dockerfile:1&lt;br&gt;FROM dunglas/frankenphp:php8.5 AS base&lt;br&gt;WORKDIR /app&lt;br&gt;RUN install-php-extensions \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;pdo_sqlite \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;mbstring \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;intl \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;zip \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;gd \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;xml \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;opcache&lt;br&gt;COPY --from=composer:2 /usr/bin/composer /usr/bin/composer&lt;br&gt;&lt;br&gt;COPY docker/opcache.ini /usr/local/etc/php/conf.d/zz-opcache.ini&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;# syntax=docker/dockerfile:1&lt;/code&gt;: We tell Docker to use version 1 Dockerfile syntax and features. Standard boilerplate.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;FROM dunglas/frankenphp:php8.5 AS base&lt;/code&gt;: We are setting up an image (calling it &lt;code&gt;base&lt;/code&gt; to reference it later) based on the official FrankenPHP image. It bundles FrankenPHP, PHP 8.5, and Caddy.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;WORKDIR /app&lt;/code&gt;: sets &lt;code&gt;/app&lt;/code&gt; as working directory where all later commands run from&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RUN install-php-extensions ...&lt;/code&gt;: Install the PHP extensions our app needs.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COPY --from=composer:2 /usr/bin/composer /usr/bin/composer&lt;/code&gt;: This I thought was a pretty cool trick! This line grabs the composer binary from the official &lt;code&gt;composer:2&lt;/code&gt; image and just drops it in here, so we don&amp;rsquo;t need to install it manually.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COPY docker/opcache.ini /usr/local/etc/php/conf.d/zz-opcache.ini&lt;/code&gt;: This copies some opcache tuning in: as the code inside the image is immutable, we can instruct opcache to be really aggressive with its caching. Here is the content of my opcache.ini:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;opcache.enable=1&lt;br&gt;opcache.enable_cli=1&lt;br&gt;opcache.memory_consumption=128&lt;br&gt;opcache.interned_strings_buffer=16&lt;br&gt;opcache.max_accelerated_files=20000&lt;br&gt;opcache.validate_timestamps=0&lt;br&gt;opcache.revalidate_freq=0&lt;br&gt;opcache.jit=tracing&lt;br&gt;opcache.jit_buffer_size=64M&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And with that our base image is complete: it contains all the binaries that our final app image needs.&lt;/p&gt;
&lt;h3 id="stage-2-php-dependencies"&gt;Stage 2: PHP dependencies&lt;/h3&gt;
&lt;p&gt;This image will install PHP dependencies into the &lt;code&gt;vendor/&lt;/code&gt; folder:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM base AS vendor&lt;br&gt;RUN apt-get update \&lt;br&gt;&amp;nbsp;&amp;amp;&amp;amp; apt-get install -y --no-install-recommends git unzip \&lt;br&gt;&amp;nbsp;&amp;amp;&amp;amp; rm -rf /var/lib/apt/lists/*&lt;br&gt;COPY composer.json composer.lock ./&lt;br&gt;RUN composer install --no-dev --no-interaction --prefer-dist \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;--optimize-autoloader --no-scripts&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;FROM base AS vendor&lt;/code&gt;: build on the base, so it already has PHP + Composer.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RUN apt-get ...&lt;/code&gt;: installs &lt;code&gt;git&lt;/code&gt; and &lt;code&gt;unzip&lt;/code&gt; as they are required by composer, then deletes the package manager cache to keep the image small.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COPY composer.json composer.lock ./&lt;/code&gt;: From our repo, copy just these two files: It&amp;rsquo;s all composer needs&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RUN composer install ...&lt;/code&gt;: Install production dependencies. The &lt;code&gt;--no-scripts&lt;/code&gt; flag skips Laravel&amp;rsquo;s package discovery. We can&amp;rsquo;t run this, as that would require booting the app, which isn&amp;rsquo;t in the image, because we only copied the composer.json and lock files.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="stage-3-asset-compilation"&gt;Stage 3: Asset compilation&lt;/h3&gt;
&lt;p&gt;This will build the frontend assets. This stage doesn&amp;rsquo;t use our &lt;code&gt;base&lt;/code&gt; image or PHP. It just uses Node (which our production image won&amp;rsquo;t need):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM node:22-alpine AS assets&lt;br&gt;WORKDIR /app&lt;br&gt;COPY package.json package-lock.json ./&lt;br&gt;COPY resources ./resources&lt;br&gt;COPY public ./public&lt;br&gt;COPY vite.config.js tailwind.config.js postcss.config.js ./&lt;br&gt;COPY --from=vendor /app/vendor ./vendor&lt;br&gt;RUN npm ci&lt;br&gt;RUN npm run build&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;FROM node:22-alpine AS assets&lt;/code&gt;: This time we are starting from a separate lightweight Node 22 image.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COPY ...&lt;/code&gt;: We&amp;rsquo;ll again need to copy some stuff so that Node can build:
&lt;ul&gt;
&lt;li&gt;package json and lock files to define dependencies&lt;/li&gt;
&lt;li&gt;&lt;code&gt;resources&lt;/code&gt; folder as that&amp;rsquo;s where our JS and CSS dependencies live&lt;/li&gt;
&lt;li&gt;vite, tailwind, and postcss configs, as these define the configs for our build&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;vendor&lt;/code&gt; folder from the &lt;code&gt;vendor&lt;/code&gt; image (built in step 2): This was a gotcha: Some Laravel dependencies ship their own JS/CSS so we need this here&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RUN...&lt;/code&gt;: Install dependencies and build assets. The built assets will end up in &lt;code&gt;public/build&lt;/code&gt; (or wherever else your &lt;code&gt;vite.config.js&lt;/code&gt; instructs them to go).&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="stage-4-the-app-image"&gt;Stage 4: The app image&lt;/h3&gt;
&lt;p&gt;Finally we are getting there. This is going to be the image that actually gets shipped and run in production. It primarily cherry picks parts from the preceding steps:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;FROM base AS app&lt;br&gt;COPY --from=vendor /app/vendor ./vendor&lt;br&gt;COPY --from=assets /app/public/build ./public/build&lt;br&gt;COPY . .&lt;br&gt;RUN composer dump-autoload --no-dev --optimize&lt;br&gt;RUN chown -R www-data:www-data /app/bootstrap/cache /app/storage /config&lt;br&gt;ENV SERVER_NAME=&amp;#34;:80&amp;#34;&lt;br&gt;EXPOSE 80&lt;br&gt;HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \&lt;br&gt;&amp;nbsp;&amp;nbsp;CMD php -r &amp;#34;exit(@file_get_contents(&amp;#39;http://127.0.0.1:80/up&amp;#39;) === false ? 1 : 0);&amp;#34;&lt;br&gt;COPY docker/entrypoint.sh /usr/local/bin/entrypoint&lt;br&gt;RUN chmod +x /usr/local/bin/entrypoint&lt;br&gt;ENTRYPOINT [&amp;#34;entrypoint&amp;#34;]&lt;br&gt;CMD [&amp;#34;frankenphp&amp;#34;, &amp;#34;run&amp;#34;, &amp;#34;--config&amp;#34;, &amp;#34;/etc/frankenphp/Caddyfile&amp;#34;]&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;FROM base AS app&lt;/code&gt;: We start again with the base image that has FrankenPHP + composer&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COPY FROM vendor ...&lt;/code&gt;, &lt;code&gt;COPY FROM assets&lt;/code&gt;: Copy the installed dependencies and assets.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COPY . .&lt;/code&gt;: Copy the application code. We&amp;rsquo;ll define a separate &lt;code&gt;.dockerignore&lt;/code&gt; file that will ignore the &lt;code&gt;vendor&lt;/code&gt; folder so it shouldn&amp;rsquo;t override things.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;RUN composer dump-autoload ...&lt;/code&gt;: As we now have both dependencies and application code the app will boot cleanly, so we can dump the auto loader, and run laravel&amp;rsquo;s package discovery.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Our app image is now fundamentally ready, but we need to wire it up to actually run something with the stuff we just built, and that&amp;rsquo;s the rest of these files:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;RUN chown -R www-data:www-data&lt;/code&gt; we change the owner of all the generated files - don&amp;rsquo;t want to run the app as &lt;code&gt;root&lt;/code&gt; any more than we&amp;rsquo;d do in a bare metal install.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ENV SERVER_NAME=&amp;quot;:80&amp;quot;&lt;/code&gt;: Tells FrankenPHP to serve plain HTTP on port 80. We&amp;rsquo;ll have an &lt;a href="https://blog.thms.uk/2026/06/oracle-caddy-tailscale-proxy"&gt;upstream proxy in front of this server&lt;/a&gt;, as it lives in my home lab.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;EXPOSE 80&lt;/code&gt;: Documents that the container listens on port 80.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;HEALTHCHECK ...&lt;/code&gt;: Defines a healthcheck — PHP fetches Laravel&amp;rsquo;s &lt;code&gt;/up&lt;/code&gt; route. Docker reports the result as the container&amp;rsquo;s health status (visible in &lt;code&gt;docker ps&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COPY docker/entrypoint.sh...&lt;/code&gt;, &lt;code&gt;RUN chmod +x...&lt;/code&gt;: Copy the entry point and make executable. We&amp;rsquo;ll get back to that file later, but this is the script that will run when the container starts up.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ENTRYPOINT [&amp;quot;entrypoint&amp;quot;]&lt;/code&gt;: Tells docker to actually run the entry point script on startup.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CMD [&amp;quot;frankenphp&amp;quot;, &amp;quot;run&amp;quot;, &amp;quot;--config&amp;quot;, ...]&lt;/code&gt;: The command the entrypoint hands off to: start the FrankenPHP web server with its bundled Caddyfile config.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="result"&gt;Result&lt;/h3&gt;
&lt;p&gt;We&amp;rsquo;ll end up with a final &lt;code&gt;app&lt;/code&gt; image that contains only PHP, application code, dependencies, and compiled assets. All the build machinery (Node, npm, git) live in throwaway stages that never ship, which makes the &lt;code&gt;app&lt;/code&gt; image as small as it can be, and reduces attack surface.&lt;/p&gt;
&lt;h2 id="the-docker-ignore-file"&gt;The Docker ignore file&lt;/h2&gt;
&lt;p&gt;I mentioned this earlier. Think of it as &amp;lsquo;gitignore for docker&amp;rsquo;: There will be plenty of stuff in your git repo, or generated during the build process that you don&amp;rsquo;t want in the docker image. Here is mine, stored at &lt;code&gt;.dockerignore&lt;/code&gt; in the repo root:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code class="language-.dockerignore" data-lang=".dockerignore"&gt;.git
.gitignore
node_modules
vendor
public/build
# Secrets + local state never go in the image
.env
.env.*
data
*.sqlite
# bootstrap caches. Laravel regenerates them at runtime.
bootstrap/cache/*.php
# Ephemeral storage contents (recreated by the entrypoint)
storage/app/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
storage/logs/*
# Docker + dev files (note: docker/entrypoint.sh is intentionally NOT listed
# here — the Dockerfile COPYs it into the image, so it must stay in the context)
Dockerfile
docker-compose.yml
.dockerignore
README.md
tests
phpunit.xml
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="the-entrypoint"&gt;The Entrypoint&lt;/h2&gt;
&lt;p&gt;Let&amp;rsquo;s have a look at the entry point script. In a nutshell the entry point:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Ensures Laravel&amp;rsquo;s required directories (&lt;code&gt;./storage/*&lt;/code&gt;, &lt;code&gt;bootstrap/cache&lt;/code&gt;) as well as the SQLite database file exist (needed because &lt;code&gt;./storage&lt;/code&gt; and &lt;code&gt;./data&lt;/code&gt; are bind mounts that start empty on a fresh host).&lt;/li&gt;
&lt;li&gt;Creates the &lt;code&gt;public/storage&lt;/code&gt; symlink.&lt;/li&gt;
&lt;li&gt;Fixes permissions.&lt;/li&gt;
&lt;li&gt;Runs &lt;code&gt;php artisan optimize&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Hands off to whatever command that service specified (or the default CMD).&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/sh&lt;br&gt;set -e&lt;br&gt;&lt;br&gt;# The container starts as root so this script can fix ownership on the&lt;br&gt;# bind-mounted volumes, then drops to this unprivileged user to run the app.&lt;br&gt;APP_USER=&amp;#34;${APP_USER:-www-data}&amp;#34;&lt;br&gt;DB_FILE=&amp;#34;${DB_DATABASE:-/data/database.sqlite}&amp;#34;&lt;br&gt;&lt;br&gt;# Laravel&amp;#39;s storage skeleton must exist. storage/ is a bind mount, so on a&lt;br&gt;# fresh host it starts empty and these subdirs won&amp;#39;t be there.&lt;br&gt;mkdir -p \&lt;br&gt;&amp;nbsp;&amp;nbsp;storage/framework/cache/data \&lt;br&gt;&amp;nbsp;&amp;nbsp;storage/framework/sessions \&lt;br&gt;&amp;nbsp;&amp;nbsp;storage/framework/views \&lt;br&gt;&amp;nbsp;&amp;nbsp;storage/logs \&lt;br&gt;&amp;nbsp;&amp;nbsp;storage/app/public \&lt;br&gt;&amp;nbsp;&amp;nbsp;bootstrap/cache&lt;br&gt;&lt;br&gt;# Ensure the SQLite database file exists before anything opens it.&lt;br&gt;mkdir -p &amp;#34;$(dirname &amp;#34;$DB_FILE&amp;#34;)&amp;#34;&lt;br&gt;touch &amp;#34;$DB_FILE&amp;#34;&lt;br&gt;&lt;br&gt;# public/storage -&amp;gt; storage/app/public. Created here rather than via&lt;br&gt;# `artisan storage:link` so it exists in EVERY container (incl. app) without&lt;br&gt;# booting the framework, and so it survives in the app container&amp;#39;s own fs.&lt;br&gt;ln -sfn /app/storage/app/public /app/public/storage&lt;br&gt;&lt;br&gt;# Hand ownership of everything the app writes to the unprivileged runtime user.&lt;br&gt;# Done here (as root) because ./storage and /data are bind mounts that can start&lt;br&gt;# owned by the host&amp;#39;s root on a fresh deploy.&lt;br&gt;chown -R &amp;#34;$APP_USER&amp;#34; storage bootstrap/cache &amp;#34;$(dirname &amp;#34;$DB_FILE&amp;#34;)&amp;#34; /config 2&amp;gt;/dev/null || true&lt;br&gt;chmod -R ug+rwX storage bootstrap/cache &amp;#34;$(dirname &amp;#34;$DB_FILE&amp;#34;)&amp;#34; 2&amp;gt;/dev/null || true&lt;br&gt;&lt;br&gt;# Optimise Laravel&amp;#39;s caches. Best-effort: a failure must never stop the container booting, &lt;br&gt;# because the app runs fine uncached and caching is purely an optimisation. &lt;br&gt;# Run as the app user so the cache files are owned by it, not root.&lt;br&gt;setpriv --reuid=&amp;#34;$APP_USER&amp;#34; --regid=&amp;#34;$APP_USER&amp;#34; --init-groups \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;php artisan optimize || echo &amp;#34;entrypoint: optimize failed, continuing uncached&amp;#34; &amp;gt;&amp;amp;2&lt;br&gt;&lt;br&gt;# Drop root and exec the real command (CMD or the service&amp;#39;s command) as the&lt;br&gt;# unprivileged user.&lt;br&gt;exec setpriv --reuid=&amp;#34;$APP_USER&amp;#34; --regid=&amp;#34;$APP_USER&amp;#34; --init-groups &amp;#34;$@&amp;#34;&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="docker-compose-services"&gt;Docker Compose services&lt;/h2&gt;
&lt;p&gt;With the app image and entry point ready, it&amp;rsquo;s time to define our docker compose services. We&amp;rsquo;ll need four of them. Each one inheriting a shared block:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;x-app: &amp;amp;app-common&lt;br&gt;&amp;nbsp;&amp;nbsp;image: code.thms.uk/michael/...&lt;br&gt;&amp;nbsp;&amp;nbsp;env_file: .env&lt;br&gt;&amp;nbsp;&amp;nbsp;volumes:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- ./data:/data # the SQLite database file lives here&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- ./storage:/app/storage # logs + any file-based app storage&lt;br&gt;&amp;nbsp;&amp;nbsp;restart: unless-stopped&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;x-app: &amp;amp;app-common&lt;/code&gt;: Is a yaml anchor, not a Docker feature. &lt;code&gt;x-app&lt;/code&gt; is an &amp;ldquo;extension field&amp;rdquo; (the &lt;code&gt;x-&lt;/code&gt; prefix tells Docker to ignore it as a service), and &lt;code&gt;&amp;amp;app-common&lt;/code&gt; gives the block a label. Each service pulls this block in with &lt;code&gt;&amp;lt;&amp;lt;: *app-common&lt;/code&gt;, so we define the common settings once instead of repeating them four times.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;image: code.thms.uk/michael/...&lt;/code&gt;: The image to use. I&amp;rsquo;ve uploaded my image to my Forgejo repo, where it is rebuilt automatically every time I push to &lt;code&gt;main&lt;/code&gt;. I will write a separate blog about this.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;env_file: .env&lt;/code&gt;: Load environment variables from the &lt;code&gt;.env&lt;/code&gt; file. Ensure that you define the database in there with&lt;/li&gt;
&lt;/ul&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt; DB_CONNECTION: sqlite
DB_DATABASE: /data/database.sqlite
&lt;/code&gt;&lt;/pre&gt;&lt;ul&gt;
&lt;li&gt;&lt;code&gt;volumes:&lt;/code&gt;: bind mounts that connect host folders to container folders, so data survives container restarts/rebuilds.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;restart: unless-stopped&lt;/code&gt;: if the container&amp;rsquo;s process exits, Docker restarts it automatically.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Each of the following four services will inherit the &lt;code&gt;&amp;amp;app-common&lt;/code&gt; stuff.&lt;/p&gt;
&lt;h3 id="the-init-service"&gt;The init service&lt;/h3&gt;
&lt;p&gt;This service will run once on start, migrate the database, then exit. &lt;code&gt;restart: &amp;quot;no&amp;quot;&lt;/code&gt; overrides the shared &lt;code&gt;unless-stopped&lt;/code&gt; because this is a one-shot task. We disable healthchecks for the same reason.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:&lt;br&gt;&amp;nbsp;&amp;nbsp;init:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;&amp;lt;: *app-common&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;command: php artisan migrate --force&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;restart: &amp;#34;no&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;healthcheck:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;disable: true&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="the-web-server"&gt;The web server&lt;/h3&gt;
&lt;p&gt;The next service is our web server. We use the &lt;code&gt;depends_on:&lt;/code&gt; directive to start this service only after migrations are done. Then we map the host&amp;rsquo;s port 8080 to port 80 in the container. As such we&amp;rsquo;ll point our &lt;a href="https://blog.thms.uk/2026/06/oracle-caddy-tailscale-proxy"&gt;reverse proxy&lt;/a&gt; at port 8080 on this server.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;nbsp;&amp;nbsp;app:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;&amp;lt;: *app-common&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;depends_on:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;init:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;condition: service_completed_successfully&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ports:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- &amp;#34;8080:80&amp;#34;&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="the-cron-and-queue-worker"&gt;The cron and queue worker&lt;/h3&gt;
&lt;p&gt;These are really both the same: We override the &lt;code&gt;command&lt;/code&gt; for each, disable healthchecks (no web server is running in here), and again make sure they only start after migrations are complete using &lt;code&gt;depends_on:&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;nbsp;&amp;nbsp;scheduler:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;&amp;lt;: *app-common&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;command: php artisan schedule:work&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;depends_on:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;init:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;condition: service_completed_successfully&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;healthcheck:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;disable: true&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;queue:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;lt;&amp;lt;: *app-common&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;command: php artisan queue:work --sleep=3 --tries=3 --max-time=3600&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;depends_on:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;init:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;condition: service_completed_successfully&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;healthcheck:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;disable: true&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="deploying"&gt;Deploying&lt;/h2&gt;
&lt;p&gt;To deploy this to production we now need to&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Build the image and push it to our code repository.&lt;/li&gt;
&lt;li&gt;Copy the &lt;code&gt;docker-compose.yml&lt;/code&gt; file to our host.&lt;/li&gt;
&lt;li&gt;Create a &lt;code&gt;.env&lt;/code&gt; file and put it alongside the &lt;code&gt;docker-compose.yml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;docker compose up -d&lt;/code&gt; on the host.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Updates are as simple as&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Re-building and re-pushing the image&lt;/li&gt;
&lt;li&gt;Running &lt;code&gt;docker compose pull &amp;amp;&amp;amp; docker compose up -d&lt;/code&gt; on the host.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;We&amp;rsquo;ll be automating the building and pushing using Forgejo actions, but that&amp;rsquo;s for another day.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116731424508032848"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Setting up a free Oracle VPS as a reverse proxy</title><link>https://blog.thms.uk/2026/06/oracle-caddy-tailscale-proxy/</link><guid>https://blog.thms.uk/2026/06/oracle-caddy-tailscale-proxy/</guid><pubDate>Mon, 01 Jun 2026 06:00:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/cloudflare/">cloudflare</category><category domain="https://blog.thms.uk/tag/meta/">meta</category><category domain="https://blog.thms.uk/tag/tools/">tools</category><category domain="https://blog.thms.uk/tag/caddy/">caddy</category><category domain="https://blog.thms.uk/tag/selfhosting/">selfhosting</category><category domain="https://blog.thms.uk/tag/tailscale/">tailscale</category><category domain="https://blog.thms.uk/tag/homelab/">homelab</category><description>Set up a zero-cost reverse proxy: an Oracle Always Free VPS running Caddy, linked to home origin servers via Tailscale, with automatic HTTPS and one-line site additions.</description><enclosure url="https://media.thms.uk/images/2026-06-proxying-oracle.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="setting-up-a-free-oracle-vps-as-a-reverse-proxy"&gt;Setting up a free Oracle VPS as a reverse proxy&lt;/h1&gt;
&lt;p&gt;Over the last few months I&amp;rsquo;ve gradually moved all my web services onto a little machine running in a closet at home.&lt;/p&gt;
&lt;p&gt;Until now I&amp;rsquo;ve relied on Cloudflare Tunnels to let external traffic reach that home server, and deployed all my static pages to &lt;a href="https://blog.thms.uk/2026/05/forgejo-cloudflare-pages"&gt;Cloudflare Pages&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;In this post I&amp;rsquo;ll walk through how I set up a free Oracle VPS as a reverse proxy to replace those Cloudflare Tunnels.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;m not quite sure what I&amp;rsquo;ll do about my Cloudflare Pages yet — I quite like the simplicity of these.&lt;/p&gt;
&lt;h2 id="basic-architecture"&gt;Basic architecture&lt;/h2&gt;
&lt;p&gt;Here&amp;rsquo;s the outline of what we&amp;rsquo;ll end up with:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;One edge server.&lt;/strong&gt; A VPS on Oracle&amp;rsquo;s Always Free tier, though it could just as easily live with any other provider, or even be self-hosted if you have a static IP. It runs Caddy to handle reverse proxying and TLS certificates.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Any number of origin servers.&lt;/strong&gt; In my case, VMs hosted on a Proxmox box in that closet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A tailnet to connect them all.&lt;/strong&gt; Tailscale runs on every server, and they talk to one another over their tailnet IP addresses / hostnames.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Traffic enters through the edge server, which handles TLS termination and reverse-proxies to the right host/port combination based on the hostname.&lt;/p&gt;
&lt;h2 id="set-up-the-always-free-server"&gt;Set up the Always Free server&lt;/h2&gt;
&lt;p&gt;Oracle&amp;rsquo;s Cloud free tier is really generous: you can run Ampere ARM instances with up to 4 CPUs and 24 GB of RAM, which is plenty for an edge proxy.&lt;/p&gt;
&lt;p&gt;The catch is twofold. The Cloud console is cumbersome to navigate, and Oracle heavily restricts the provisioning of free Ampere ARM instances on trial accounts. To get around this, I upgraded to a Pay As You Go (PAYG) account. As long as you stay within the free tier you still won&amp;rsquo;t be charged, but the provisioning restriction goes away. All you need is a credit/debit card: Oracle places a temporary $100 hold as part of the upgrade, but they don&amp;rsquo;t actually collect anything, so it stays completely free.&lt;/p&gt;
&lt;p&gt;I also set up a billing alert to warn me if my spend ever exceeds $0.10 in a month, just in case I accidentally provision something that incurs a cost.&lt;/p&gt;
&lt;p&gt;I won&amp;rsquo;t cover the upgrade steps in detail here; your preferred search engine or LLM will get you there.&lt;/p&gt;
&lt;h2 id="install-tailscale"&gt;Install Tailscale&lt;/h2&gt;
&lt;p&gt;With the bare server up, install Tailscale on it:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://login.tailscale.com/admin/machines/new-linux"&gt;https://login.tailscale.com/admin/machines/new-linux&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Paste and run the generated script on the new server so it joins your existing tailnet.&lt;/li&gt;
&lt;li&gt;Optionally, enable Tailscale SSH. This lets you block SSH at the firewall so only tailnet members can reach the edge server:
&lt;pre&gt;&lt;code&gt;sudo tailscale set --ssh&lt;/code&gt;&lt;/pre&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;You can now close port 22 on your firewall — and while you&amp;rsquo;re there, open ports 80 and 443 for HTTP(S) traffic.&lt;/p&gt;
&lt;h2 id="install-docker"&gt;Install Docker&lt;/h2&gt;
&lt;p&gt;We&amp;rsquo;ll use Docker to manage Caddy on the edge server. The built-in repository would work, but I prefer the official one, so let&amp;rsquo;s start by adding Docker&amp;rsquo;s GPG key to apt:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo install -m 0755 -d /etc/apt/keyrings&lt;br&gt;curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg&lt;br&gt;sudo chmod a+r /etc/apt/keyrings/docker.gpg&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then add the Docker repository itself:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;echo &amp;#34;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable&amp;#34; | sudo tee /etc/apt/sources.list.d/docker.list &amp;gt; /dev/null&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Update the package list and install Docker:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo apt update&lt;br&gt;sudo apt install docker-ce&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Finally, I always add the current user to the &lt;code&gt;docker&lt;/code&gt; group so we don&amp;rsquo;t need &lt;code&gt;sudo&lt;/code&gt; every time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo usermod -aG docker ${USER}&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="set-up-caddy-with-docker"&gt;Set up Caddy with Docker&lt;/h2&gt;
&lt;p&gt;Create a directory and change into it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;mkdir caddy&lt;br&gt;cd caddy&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Create a &lt;code&gt;docker-compose.yml&lt;/code&gt; file:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;services:&lt;br&gt;&amp;nbsp;&amp;nbsp;caddy:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;image: caddy:latest&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;container_name: caddy&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;restart: unless-stopped&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;ports:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- &amp;#34;80:80&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- &amp;#34;443:443&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;volumes:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- ./Caddyfile:/etc/caddy/Caddyfile&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- caddy_data:/data&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- caddy_config:/config&lt;br&gt;&lt;br&gt;volumes:&lt;br&gt;&amp;nbsp;&amp;nbsp;caddy_data:&lt;br&gt;&amp;nbsp;&amp;nbsp;caddy_config:&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Create a &lt;code&gt;Caddyfile&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# optionally redirect `www.` to the bare domain&lt;br&gt;www.example.com {&lt;br&gt;&amp;nbsp;&amp;nbsp;redir https://example.com{uri}&lt;br&gt;}&lt;br&gt;# reverse proxy to the backend server using its Tailnet IP address (or hostname)&lt;br&gt;example.com {&lt;br&gt;&amp;nbsp;&amp;nbsp;reverse_proxy 100.x.y.z&lt;br&gt;}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Since we&amp;rsquo;re on the tailnet, you can use the hostname here instead of the IP address if you prefer.&lt;/p&gt;
&lt;h2 id="point-your-dns"&gt;Point your DNS&lt;/h2&gt;
&lt;p&gt;If you haven&amp;rsquo;t already, point your domain at the edge server&amp;rsquo;s public IP address before continuing.&lt;/p&gt;
&lt;h2 id="start-caddy"&gt;Start Caddy&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;docker compose up -d&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;That&amp;rsquo;s it. In the background, Caddy provisions a TLS certificate, and within a few seconds your new site is served through the edge server.&lt;/p&gt;
&lt;h2 id="adding-more-sites"&gt;Adding more sites&lt;/h2&gt;
&lt;p&gt;To put another site behind the reverse proxy, just add it to your &lt;code&gt;Caddyfile&lt;/code&gt;:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;example.org {
reverse_proxy 100.x.y.z
}
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;then validate and reload Caddy:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;docker exec caddy caddy validate --config /etc/caddy/Caddyfile&lt;br&gt;docker exec caddy caddy reload --config /etc/caddy/Caddyfile&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;That&amp;rsquo;s a complete, free reverse-proxy setup: an Oracle Always Free edge server running Caddy, connected to your home servers over Tailscale, with automatic TLS and a one-line process for adding new sites. Compared to Cloudflare Tunnels, you get something you fully control, with no single provider sitting in front of your ingress — and it costs nothing to run.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116675647729203291"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Auto-Deploying a static site from Forgejo to CloudFlare Pages</title><link>https://blog.thms.uk/2026/05/forgejo-cloudflare-pages/</link><guid>https://blog.thms.uk/2026/05/forgejo-cloudflare-pages/</guid><pubDate>Wed, 20 May 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/forgejo/">forgejo</category><category domain="https://blog.thms.uk/tag/meta/">meta</category><category domain="https://blog.thms.uk/tag/tools/">tools</category><category domain="https://blog.thms.uk/tag/cloudflare/">cloudflare</category><category domain="https://blog.thms.uk/tag/selfhosting/">selfhosting</category><description>A step-by-step guide to deploying a static site from a self-hosted Forgejo instance to CloudFlare Pages, using a Forgejo Action that redeploys automatically on every push to main.</description><enclosure url="https://media.thms.uk/images/2026-05-forgejo-cloudflare-pages/header-1.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="auto-deploying-a-static-site-from-forgejo-to-cloudflare-pages"&gt;Auto-Deploying a static site from Forgejo to CloudFlare Pages&lt;/h1&gt;
&lt;p&gt;When migrating from GitHub to my self-hosted Forgejo instance, I&amp;rsquo;ve lost the ability to use GitHub Pages to host a couple of static pages I have been hosting on GitHub.&lt;/p&gt;
&lt;p&gt;One alternative is CloudFlare Pages. And whilst CloudFlare Pages has a direct integration with GitHub to deploy from GitHub to CloudFlare, no such direct integration exists with Forgejo.&lt;/p&gt;
&lt;p&gt;This post walks us through setting up a Forgejo Action that will auto-deploy to CloudFlare Pages on every push.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you want to fully self host your static site, you may wish to have a look at how I&amp;rsquo;m self hosting my blog using &lt;a href="https://blog.thms.uk/2026/06/self-hosted-blog-forgejo/"&gt;Forgejo, Caddy, and Tailscale&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="prerequisites"&gt;Prerequisites&lt;/h2&gt;
&lt;p&gt;Here is what you need before you get started:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A Forgejo account on an instance with a runner configured. If you are self hosting look at the &lt;a href="https://forgejo.org/docs/latest/admin/actions/"&gt;Forgejo docs on setting up a runner&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;A CloudFlare account.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="creating-your-cloudflare-page"&gt;Creating your CloudFlare Page&lt;/h2&gt;
&lt;p&gt;Before you can deploy from your Forgejo action you must manually create a CloudFlare Page, and upload the first deployment manually by uploading it in the CloudFlare UI:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to dash.cloudflare.com, and under Build &amp;gt; Compute, find Workers &amp;amp; Pages.&lt;/li&gt;
&lt;li&gt;Click the blue Create Application button.&lt;/li&gt;
&lt;li&gt;Find the small &amp;ldquo;Looking to deploy Pages? Get started&amp;rdquo; text underneath the card - CloudFlare are hiding it these days.
&lt;img src="https://media.thms.uk/images/2026-05-forgejo-cloudflare-pages/cloudflare-pages.png?v=42c1908a3b812ae400711236f1fe8826" alt="CloudFlare Pages creation"&gt;&lt;/li&gt;
&lt;li&gt;On the next page for &amp;ldquo;Drag and drop your files&amp;rdquo; click Get Started.&lt;/li&gt;
&lt;li&gt;Give it a name such as &lt;code&gt;my-page&lt;/code&gt; and click &amp;lsquo;Create project&amp;rsquo;. You&amp;rsquo;ll need that name later, as that&amp;rsquo;s how CloudFlare identifies your page.&lt;/li&gt;
&lt;li&gt;Upload the files or folders for your first deployment (You could always just upload a folder with a single &lt;code&gt;index.html&lt;/code&gt; file if you wanted to at this stage).&lt;/li&gt;
&lt;li&gt;Click &amp;lsquo;Deploy site&amp;rsquo;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Your page is now ready, and accessible at &lt;code&gt;https://my-page.pages.dev&lt;/code&gt; (or whatever else you may have chosen for your name).&lt;/p&gt;
&lt;p&gt;You can set up your custom domain now, if you wish, as well as any other configuration options.&lt;/p&gt;
&lt;h2 id="getting-a-cloudflare-access-token-and-account-id"&gt;Getting a CloudFlare Access Token and Account ID&lt;/h2&gt;
&lt;p&gt;Next we&amp;rsquo;ll need to get your Account ID and generate an Access Token for the automated deployments:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Go to Manage Account &amp;gt; Account API Tokens and click &amp;lsquo;Create Token&amp;rsquo;.&lt;/li&gt;
&lt;li&gt;Assign the correct permissions: You&amp;rsquo;ll need Read and Edit access for Pages (which is found under the Developer Platform heading). Set your expiration and/or IP address restrictions if desired.&lt;/li&gt;
&lt;li&gt;On the next page you&amp;rsquo;ll be presented with your Account ID and your API Token. Take a note of both. You&amp;rsquo;ll need them.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="configure-your-forgejo-action"&gt;Configure your Forgejo Action&lt;/h2&gt;
&lt;p&gt;We&amp;rsquo;ll need to provide the Account ID and Token to Forgejo as Secrets. That way they stay out of our code and hidden from prying eyes: Go to your repository settings, and find Actions &amp;gt; Secrets in the left column. Provide two secrets: &lt;code&gt;CLOUDFLARE_ACCOUNT_ID&lt;/code&gt; and &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt;. It should look like this:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://media.thms.uk/images/2026-05-forgejo-cloudflare-pages/foregjo-settings.png?v=c448dee6c0afb4e61cd7193e14a07516" alt="Forgejo Repository Settings"&gt;&lt;/p&gt;
&lt;h2 id="creating-your-forgejo-action"&gt;Creating your Forgejo Action&lt;/h2&gt;
&lt;p&gt;In your repository create a file at &lt;code&gt;.forgejo/workflows/deploy.yml&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Paste the following content:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;on:&lt;br&gt;&amp;nbsp;&amp;nbsp;workflow_dispatch:&lt;br&gt;&amp;nbsp;&amp;nbsp;push:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;branches: [main] # replace branch name, if you are not using `main`&lt;br&gt;&lt;br&gt;jobs:&lt;br&gt;&amp;nbsp;&amp;nbsp;deploy:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;runs-on: ubuntu-latest&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;steps:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- uses: actions/checkout@v4&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- uses: actions/setup-node@v4&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;with:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;node-version: 22&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- name: npm install&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;run: npm install&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- name: build&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;run: npm run build&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- name: Deploy to Cloudflare Pages&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;run: npx wrangler@latest pages deploy ./dist --project-name=my-page # replace `./dist` with your build output directory, and `my-page` with your name chosen earlier&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;env:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This action makes the following assumptions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;You will be deploying from your &lt;code&gt;main&lt;/code&gt; branch - you can adjust the branch on line 4 if needed.&lt;/li&gt;
&lt;li&gt;We&amp;rsquo;ll also add the &lt;code&gt;workflow_dispatch&lt;/code&gt; trigger. This will enable a button in the Forgejo UI to allow you to trigger a manual deploy should you wish to do so.&lt;/li&gt;
&lt;li&gt;You are using &lt;code&gt;npm install&lt;/code&gt; and &lt;code&gt;npm run build&lt;/code&gt; as your build process. If you don&amp;rsquo;t need those, you can remove those steps. if you need different steps you can replace or add them.&lt;/li&gt;
&lt;li&gt;Your build output directory is &lt;code&gt;./dist&lt;/code&gt; and your page name &lt;code&gt;my-page&lt;/code&gt; - replace on line 23 if needed.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;If you don&amp;rsquo;t use any build process you can skip the install and build steps. E.g. I have one repo that is just static HTML, CSS and JS. In this case I just need to delete the &lt;code&gt;.git&lt;/code&gt; and &lt;code&gt;.forgejo&lt;/code&gt; directories before deployment:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;on:&lt;br&gt;&amp;nbsp;&amp;nbsp;workflow_dispatch:&lt;br&gt;&amp;nbsp;&amp;nbsp;push:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;branches: [main]&lt;br&gt;&lt;br&gt;jobs:&lt;br&gt;&amp;nbsp;&amp;nbsp;deploy:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;runs-on: ubuntu-latest&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;steps:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- uses: actions/checkout@v4&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- uses: actions/setup-node@v4&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;with:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;node-version: 22&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- name: Delete unwanted files&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;run: |&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rm -rf .forgejo&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;rm -rf .git&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;- name: Deploy to Cloudflare Pages&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;run: npx wrangler@latest pages deploy ./ --project-name=my-page&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;env:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And that&amp;rsquo;s it - your site will be deployed to CloudFlare Pages automatically every time you push to your Forgejo instance.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116606293060598750"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Re-verifying failed backups with Proxmox Backup Server</title><link>https://blog.thms.uk/2026/05/pbs-reverify/</link><guid>https://blog.thms.uk/2026/05/pbs-reverify/</guid><pubDate>Tue, 05 May 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/proxmox/">proxmox</category><category domain="https://blog.thms.uk/tag/meta/">meta</category><category domain="https://blog.thms.uk/tag/tools/">tools</category><description>A simple script to re-verify failed backups in Proxmox Backup Server</description><enclosure url="https://media.thms.uk/images/2026-05-pbs-reverify.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="re-verifying-failed-backups-with-proxmox-backup-server"&gt;Re-verifying failed backups with Proxmox Backup Server&lt;/h1&gt;
&lt;p&gt;I&amp;rsquo;m running Proxmox Backup Server using Backblaze B2 (an S3 compatible object storage service) for storing backups. This does work fine, but my scheduled verification (I verify once a week) regularly fails on some backups due to what appears to be transient issues with the B2 API. I simply get these entirely unhelpful entries in my verification log:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;verify backblaze:ct/{id}/{timestamp}/root.pxar.didx failed: chunks could not be verified
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;I wanted to have a simple script that would attempt a re-verification job of any backups that are currently marked as &amp;lsquo;failed&amp;rsquo; on the backup store, so I can simply re-try: If verification still fails I assume the backup is broken. But otherwise I can just move on.&lt;/p&gt;
&lt;p&gt;The script is pretty straightforward: First get a list of failed backups, then create a new verification ensuring we pass in &lt;code&gt;--ignore-verified false&lt;/code&gt; so it actually re-verifies.&lt;/p&gt;
&lt;p&gt;You can run the script with &lt;code&gt;--dry-run&lt;/code&gt; to see a list of any backups that would be re-verified.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;#!/bin/bash&lt;br&gt;DATASTORE=&amp;#34;backblaze&amp;#34; # replace with your datastore name!&lt;br&gt;&lt;br&gt;# Verify Dry Run&lt;br&gt;DRY_RUN=false&lt;br&gt;if [‎[ &amp;#34;$1&amp;#34; == &amp;#34;--dry-run&amp;#34; ]‎]; then&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;DRY_RUN=true&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;echo &amp;#34;&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;echo &amp;#34;-------------------------------------------------&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;echo &amp;#34;Dry run mode — no verification will be triggered.&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;echo &amp;#34;-------------------------------------------------&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;echo &amp;#34;&amp;#34;&lt;br&gt;fi&lt;br&gt;&lt;br&gt;# Wait while a verification job is running - we don&amp;#39;t want to have multiple jobs running in parallel&lt;br&gt;while proxmox-backup-debug api get /nodes/pbs/tasks \&lt;br&gt;&amp;nbsp;&amp;nbsp;--running true \&lt;br&gt;&amp;nbsp;&amp;nbsp;--output-format json 2&amp;gt;/dev/null \&lt;br&gt;&amp;nbsp;&amp;nbsp;| jq -e &amp;#39;[.[] | select(.worker_type == &amp;#34;verificationjob&amp;#34; or .worker_type == &amp;#34;verify_snapshot&amp;#34;)] | length &amp;gt; 0&amp;#39; &amp;gt; /dev/null 2&amp;gt;&amp;amp;1; do&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;echo &amp;#34;Verification job still running, waiting 60s...&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sleep 60&lt;br&gt;done&lt;br&gt;&lt;br&gt;# get list of failed jobs&lt;br&gt;FAILED=$(proxmox-backup-debug api get /admin/datastore/$DATASTORE/snapshots \&lt;br&gt;&amp;nbsp;&amp;nbsp;--output-format json 2&amp;gt;/dev/null \&lt;br&gt;&amp;nbsp;&amp;nbsp;| jq -r &amp;#39;.[] | select(.verification.state == &amp;#34;failed&amp;#34;) | &amp;#34;\(.[&amp;#34;backup-type&amp;#34;]) \(.[&amp;#34;backup-id&amp;#34;]) \(.[&amp;#34;backup-time&amp;#34;])&amp;#34;&amp;#39;)&lt;br&gt;&lt;br&gt;# Bail out if nothing has failed&lt;br&gt;if [ -z &amp;#34;$FAILED&amp;#34; ]; then&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;echo &amp;#34;No failed backups found.&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;exit 0&lt;br&gt;fi&lt;br&gt;&lt;br&gt;# Re-Verify any failed jobs&lt;br&gt;while IFS=&amp;#39; &amp;#39; read -r BTYPE BID BTIME; do&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;LABEL=&amp;#34;$BTYPE/$BID/$(date -u -d &amp;#34;@$BTIME&amp;#34; &amp;#39;+%Y-%m-%dT%H:%M:%SZ&amp;#39;)&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;echo &amp;#34;Re-verifying $LABEL...&amp;#34;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if [ &amp;#34;$DRY_RUN&amp;#34; = false ]; then&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxmox-backup-debug api create /admin/datastore/$DATASTORE/verify \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;--backup-type &amp;#34;$BTYPE&amp;#34; \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;--backup-id &amp;#34;$BID&amp;#34; \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;--backup-time &amp;#34;$BTIME&amp;#34; \&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;--ignore-verified false&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;fi&lt;br&gt;done &amp;lt;&amp;lt;&amp;lt; &amp;#34;$FAILED&amp;#34;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;As I&amp;rsquo;m running my verifications on schedule on Sunday evenings, I&amp;rsquo;ve scheduled the re-verification for early Monday mornings:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code class="language-cron" data-lang="cron"&gt;0 1 * * 1 /path/to/reverify-backups.sh &amp;gt; /path/to/reverify-backups.log 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116522709143667826"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>FreshRSS mobile improvements</title><link>https://blog.thms.uk/2026/03/freshrss-mobile/</link><guid>https://blog.thms.uk/2026/03/freshrss-mobile/</guid><pubDate>Mon, 23 Mar 2026 01:00:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/freshrss/">freshrss</category><description>Using FreshRSS's Custom CSS and JS extensions to improve the mobile experience: flex-based multi-line article layout, summary spacing, and scroll-aware footer.</description><enclosure url="https://media.thms.uk/images/2026-03-freshrss-mobile.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="freshrss-mobile-improvements"&gt;FreshRSS mobile improvements&lt;/h1&gt;
&lt;p&gt;I have recently started using a self-hosted version of &lt;a href="https://github.com/FreshRSS/FreshRSS"&gt;FreshRSS&lt;/a&gt; to aggregate RSS feeds. I love it, and the web version is pretty good, but the mobile version needed some work for my taste.
I have used the Custom CSS and Custom JS extensions to make some minor adjustments:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Converted the article list into multiple lines.&lt;/li&gt;
&lt;li&gt;Added some more space for the article summary.&lt;/li&gt;
&lt;li&gt;Hide the fixed footer bar when scrolling.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Here is a before/after comparison:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://media.thms.uk/images/2026-03-freshrss-mobile-comparison.png?v=b020e23c165c69ef772fb9b0b8df4dc7" alt="Before / After Comparison"&gt;&lt;/p&gt;
&lt;h2 id="custom-css"&gt;Custom CSS&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;Navigate to Configuration &amp;gt; Extensions.&lt;/li&gt;
&lt;li&gt;Click the gears button next to Custom CSS. (Don&amp;rsquo;t forget to activate it.)&lt;/li&gt;
&lt;li&gt;Paste the below.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code&gt;.flux .flux_header .item .summary {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/* Remove excessive white space from summary */&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;white-space: normal; &lt;br&gt;}&lt;br&gt;.flux .flux_header .item .item-element.bookmark {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/* Remove ellipsis from bookmark/favourite icon */&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;text-overflow: clip;&lt;br&gt;}&lt;br&gt;@media (max-width: 840px) {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/* Set up layout */&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.flux .flux_header:has(.thumbnail):has(.summary) {&lt;br&gt; display: grid;&lt;br&gt; grid-template-columns: auto 1fr auto auto auto;&lt;br&gt; grid-template-rows: auto auto;&lt;br&gt; grid-template-areas:&lt;br&gt; &amp;#34;website website manage-read manage-bookmark manage-share manage-labels link&amp;#34;&lt;br&gt; &amp;#34;thumbnail content content content content content content&amp;#34;;&lt;br&gt; align-items: start;&lt;br&gt; list-style: none;&lt;br&gt; }&lt;br&gt; /** place all the elements in their respective template areas */&lt;br&gt; .flux .flux_header:has(.thumbnail):has(.summary) li.website {&lt;br&gt; grid-area: website;&lt;br&gt; }&lt;br&gt; .flux .flux_header:has(.thumbnail):has(.summary) li.thumbnail {&lt;br&gt; grid-area: thumbnail;&lt;br&gt; }&lt;br&gt; .flux .flux_header:has(.thumbnail):has(.summary) li.titleAuthorSummaryDate {&lt;br&gt; grid-area: content;&lt;br&gt; }&lt;br&gt; .flux .flux_header:has(.thumbnail):has(.summary) li.manage:has(.read) {&lt;br&gt; grid-area: manage-read;&lt;br&gt; }&lt;br&gt; .flux .flux_header:has(.thumbnail):has(.summary) li.manage:has(.bookmark) {&lt;br&gt; grid-area: manage-bookmark;&lt;br&gt; }&lt;br&gt; .flux .flux_header:has(.thumbnail):has(.summary) li.link {&lt;br&gt; grid-area: link;&lt;br&gt; }&lt;br&gt; .flux .flux_header:has(.thumbnail):has(.summary) li.labels {&lt;br&gt; grid-area: manage-labels&lt;br&gt; }&lt;br&gt; .flux .flux_header:has(.thumbnail):has(.summary) li.share {&lt;br&gt; grid-area: manage-share&lt;br&gt; }&lt;br&gt;&lt;br&gt; /** Show feed name */&lt;br&gt; .flux .flux_header:has(.thumbnail):has(.summary) li.website {&lt;br&gt; width: auto;&lt;br&gt; }&lt;br&gt; .flux .flux_header:has(.thumbnail):has(.summary) li.website .websiteName {&lt;br&gt; display: inline;&lt;br&gt; }&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.flux .flux_header:has(.thumbnail):has(.summary) .item .summary {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/* This now gives us space for a 3rd row in the summary */&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-webkit-line-clamp: 3;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;/* Hide footer scrolling down */&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;#nav_entries {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;transition: transform 0.3s ease;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;body.scrolling-down #nav_entries {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;transform: translateY(100%);&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}&lt;br&gt;&lt;br&gt;}&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="custom-js"&gt;Custom JS&lt;/h2&gt;
&lt;p&gt;Paste the below into Custom JS. It&amp;rsquo;s just the bit needed to get the fixed footer hidden when scrolling down:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;let lastY = 0;&lt;br&gt;&lt;br&gt;window.addEventListener(&amp;#39;scroll&amp;#39;, () =&amp;gt; {&lt;br&gt;&amp;nbsp;&amp;nbsp;const y = window.scrollY;&lt;br&gt;&amp;nbsp;&amp;nbsp;document.body.classList.toggle(&amp;#39;scrolling-down&amp;#39;, y &amp;gt; lastY &amp;amp;&amp;amp; y &amp;gt; 80);&lt;br&gt;&amp;nbsp;&amp;nbsp;lastY = y;&lt;br&gt;}, { passive: true });&lt;/code&gt;&lt;/pre&gt;&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116279149200342010"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Self-Hosting Mastodon Behind Cloudflare Tunnel</title><link>https://blog.thms.uk/2026/03/mastodon-cloudflare-tunnel/</link><guid>https://blog.thms.uk/2026/03/mastodon-cloudflare-tunnel/</guid><pubDate>Wed, 18 Mar 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/mastodon/">mastodon</category><category domain="https://blog.thms.uk/tag/selfhosting/">selfhosting</category><description>A step-by-step guide to running Mastodon behind a Cloudflare Tunnel - ideal if you're self-hosting on a domestic connection without a static IP</description><enclosure url="https://media.thms.uk/images/2026-03-mastodon-cloudflare-tunnel.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="self-hosting-mastodon-behind-cloudflare-tunnel"&gt;Self-Hosting Mastodon Behind Cloudflare Tunnel&lt;/h1&gt;
&lt;p&gt;I recently migrated my Mastodon server to my homelab. As I&amp;rsquo;m on a domestic UK internet connection, my IP address changes periodically - which rules out relying on a static public IP. I also didn&amp;rsquo;t want to forward ports 80 and 443, so I opted for Cloudflare Tunnels instead.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s how I got Mastodon running behind a Cloudflare Tunnel.&lt;/p&gt;
&lt;h2 id="set-up-mastodon"&gt;Set Up Mastodon&lt;/h2&gt;
&lt;p&gt;I covered the initial setup in my &lt;a href="https://blog.thms.uk/2023/01/setting-up-mastodon"&gt;first ever blog post&lt;/a&gt;, so I won&amp;rsquo;t repeat it here. For the migration of my existing server I followed the &lt;a href="https://docs.joinmastodon.org/admin/migrating/"&gt;official migration docs&lt;/a&gt; which are really really thorough.&lt;/p&gt;
&lt;h2 id="set-up-cloudflare-tunnel"&gt;Set Up Cloudflare Tunnel&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Install &lt;code&gt;cloudflared&lt;/code&gt;.&lt;/strong&gt; On Ubuntu or another Debian-based distro, I recommend using the Cloudflare package repository:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Add the Cloudflare GPG key&lt;br&gt;sudo mkdir -p --mode=0755 /usr/share/keyrings&lt;br&gt;curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg &amp;gt;/dev/null&lt;br&gt;&lt;br&gt;# Add the repo to your apt sources&lt;br&gt;echo &amp;#39;deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main&amp;#39; | sudo tee /etc/apt/sources.list.d/cloudflared.list&lt;br&gt;&lt;br&gt;# Install cloudflared&lt;br&gt;sudo apt-get update &amp;amp;&amp;amp; sudo apt-get install cloudflared&lt;/code&gt;&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Log in:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cloudflared tunnel login&lt;/code&gt;&lt;/pre&gt;&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Create a tunnel:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cloudflared tunnel create mastodon&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;mastodon&lt;/code&gt; here is just the internal name for the tunnel - it&amp;rsquo;s for your reference only. Cloudflare will return a tunnel UUID; keep a note of it for the next step.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Configure the tunnel&lt;/strong&gt; by creating &lt;code&gt;~/.cloudflared/config.yml&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tunnel: {uuid} # replace with your UUID here&lt;br&gt;credentials-file: /home/mastodon/.cloudflared/{uuid}.json # replace with your UUID here&lt;br&gt;&lt;br&gt;ingress:&lt;br&gt;&amp;nbsp;&amp;nbsp;- hostname: mastodon.example.com # replace with your Mastodon domain&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;service: https://127.0.0.1:443&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;originRequest:&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;httpHostHeader: mastodon.example.com # replace with your Mastodon domain&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;noTLSVerify: true&lt;br&gt;&amp;nbsp;&amp;nbsp;- service: http_status:404&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Two things to note here:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;originRequest.httpHostHeader&lt;/code&gt;&lt;/strong&gt; must be set, otherwise the underlying nginx can&amp;rsquo;t identify your virtual host.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;originRequest.noTLSVerify: true&lt;/code&gt;&lt;/strong&gt; is required because nginx won&amp;rsquo;t have a valid certificate for &lt;code&gt;127.0.0.1&lt;/code&gt;. Without this, the tunnel will refuse to connect.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Create the DNS record:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;cloudflared tunnel route dns mastodon mastodon.example.com&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Replace &lt;code&gt;mastodon&lt;/code&gt; with your tunnel name (if you used a different one in step 3), and &lt;code&gt;mastodon.example.com&lt;/code&gt; with your actual domain.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Install &lt;code&gt;cloudflared&lt;/code&gt; as a service:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;sudo cloudflared --config /home/mastodon/.cloudflared/config.yml service install&lt;/code&gt;&lt;/pre&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;That&amp;rsquo;s it. Visit &lt;code&gt;https://mastodon.example.com&lt;/code&gt; in your browser and you should land on your Mastodon instance.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116249061340704974dd"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Setting Up BIMI for Your Email</title><link>https://blog.thms.uk/2026/03/setting-up-bimi/</link><guid>https://blog.thms.uk/2026/03/setting-up-bimi/</guid><pubDate>Tue, 17 Mar 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/email/">email</category><category domain="https://blog.thms.uk/tag/tools/">tools</category><description>A step-by-step guide to setting up self-asserted BIMI: the DNS standard that puts your brand logo in recipients' inboxes, no certificate required.</description><enclosure url="https://media.thms.uk/images/2026-03-bimi.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="setting-up-bimi-for-your-email"&gt;Setting Up BIMI for Your Email&lt;/h1&gt;
&lt;p&gt;Some mail clients (e.g. Gmail, Fastmail) show sender avatars in the inbox next to the sender name. Where they don&amp;rsquo;t have a logo on file, they&amp;rsquo;ll often fall back to initials.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Brand_Indicators_for_Message_Identification"&gt;BIMI&lt;/a&gt; is a DNS-based standard that tells email clients which logo to display for your domain - a straightforward way to get your brand logo showing up in recipients&amp;rsquo; inboxes.&lt;/p&gt;
&lt;h2 id="limitations"&gt;Limitations&lt;/h2&gt;
&lt;p&gt;BIMI is far from a perfect solution. To do it properly you need a Verified Mark Certificate (VMC), which costs $500+ per year and isn&amp;rsquo;t easy to obtain. Support is also patchy - not all clients honour it, and many use their own fallback mechanisms regardless (Fastmail, for example, seems to prefer Gravatar, then the domain&amp;rsquo;s favicon).&lt;/p&gt;
&lt;h2 id="self-asserted-bimi"&gt;Self-Asserted BIMI&lt;/h2&gt;
&lt;p&gt;You can set up BIMI without a certificate, which eliminates the cost. This is known as self-asserted BIMI, and it&amp;rsquo;s currently only supported by AOL/Yahoo - so the practical value is limited. That said, it&amp;rsquo;s a straightforward process, so here&amp;rsquo;s how to do it.&lt;/p&gt;
&lt;h2 id="dmarc-requirements"&gt;DMARC Requirements&lt;/h2&gt;
&lt;p&gt;Before you start, you need a DMARC record set to either &lt;code&gt;quarantine&lt;/code&gt; or &lt;code&gt;reject&lt;/code&gt;. If you have a &lt;code&gt;pct&lt;/code&gt; value set, it must be &lt;code&gt;100&lt;/code&gt;. In short, you need strict DMARC alignment in place.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re not familiar with DMARC, I&amp;rsquo;ve written about &lt;a href="https://blog.thms.uk/2023/03/spf-dkim-dmarc#dmarc"&gt;SPF, DKIM, and DMARC&lt;/a&gt; previously - you&amp;rsquo;ll need all three configured correctly before proceeding.&lt;/p&gt;
&lt;p&gt;A minimal DMARC record looks like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;v=DMARC1; p=reject; sp=reject; pct=100;
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="the-logo"&gt;The Logo&lt;/h2&gt;
&lt;p&gt;The logo must be an SVG file, but not just any SVG - it needs to conform to the &lt;strong&gt;SVG Portable/Secure (SVG P/S)&lt;/strong&gt; standard. This is a strict (and somewhat obscure) subset of SVG that most vector editors don&amp;rsquo;t export natively. The easiest path is to export your logo as a standard SVG, then run it through a converter. I used &lt;a href="https://easydmarc.com/tools/bimi-logo-converter"&gt;EasyDMARC&amp;rsquo;s BIMI logo converter&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Once converted, open the file and update the &lt;code&gt;&amp;lt;title&amp;gt;&amp;lt;/title&amp;gt;&lt;/code&gt; element to your company name, then host it somewhere publicly accessible.&lt;/p&gt;
&lt;h2 id="setting-the-bimi-record"&gt;Setting the BIMI Record&lt;/h2&gt;
&lt;p&gt;Add a &lt;code&gt;TXT&lt;/code&gt; record at &lt;code&gt;default._bimi.&amp;lt;your-domain&amp;gt;&lt;/code&gt;. Here&amp;rsquo;s mine as an example:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;dig default._bimi.thms.uk txt +short
&amp;#34;v=BIMI1; l=https://media.thms.uk/logo-bimi.svg; avp=personal;&amp;#34;
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Breaking down the fields:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;v=BIMI1&lt;/code&gt; - identifies this as a BIMI record.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;l=https://media.thms.uk/logo-bimi.svg&lt;/code&gt; - the URL of your hosted SVG logo.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;avp=personal&lt;/code&gt; - Avatar Preference. &lt;code&gt;personal&lt;/code&gt; means the client should show the sender&amp;rsquo;s personal avatar if one exists, falling back to the BIMI logo. &lt;code&gt;brand&lt;/code&gt; always shows the BIMI logo and is the default if this field is omitted.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;a=https://example.com/certificate.pem&lt;/code&gt; - the location of your VMC certificate (&lt;code&gt;.pem&lt;/code&gt; file). Omit this if you&amp;rsquo;re going self-asserted.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="testing-your-bimi-record"&gt;Testing Your BIMI Record&lt;/h2&gt;
&lt;p&gt;MXToolbox has a dedicated BIMI checker at &lt;a href="https://mxtoolbox.com/bimi.aspx"&gt;https://mxtoolbox.com/bimi.aspx&lt;/a&gt;. Enter your domain and you&amp;rsquo;ll get results immediately.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://media.thms.uk/images/bimi-result.png?v=9a133b5d69a1a9a2a4b357611f4cf43a" alt="BIMI result for thms.uk"&gt;&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116245341758417786"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>On Serving Markdown to AI Agents</title><link>https://blog.thms.uk/2026/02/serving-markdown-to-ai-agents/</link><guid>https://blog.thms.uk/2026/02/serving-markdown-to-ai-agents/</guid><pubDate>Fri, 27 Feb 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/tools/">tools</category><category domain="https://blog.thms.uk/tag/meta/">meta</category><category domain="https://blog.thms.uk/tag/ai/">ai</category><description>Cloudflare and Spatie have both shipped tools to serve Markdown to AI agents. I tried it - and discovered ChatGPT silently rejects text/markdown responses entirely.</description><enclosure url="https://media.thms.uk/images/2026-02-serving-markdown-to-ai-agents.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="on-serving-markdown-to-ai-agents"&gt;On Serving Markdown to AI Agents&lt;/h1&gt;
&lt;p&gt;Recently the idea of serving Markdown to AI agents has gained traction. Cloudflare &lt;a href="https://blog.cloudflare.com/markdown-for-agents/"&gt;wrote about it&lt;/a&gt; and built a dashboard toggle to convert responses to Markdown automatically, using content negotiation to detect whether the requesting client wants Markdown or HTML.&lt;/p&gt;
&lt;p&gt;Spatie then released &lt;a href="https://github.com/spatie/laravel-markdown-response"&gt;laravel-markdown-response&lt;/a&gt;, a Laravel package that does much the same thing - but goes a step further by detecting AI agents via User Agent and serving them Markdown by default, even without an explicit &lt;code&gt;Accept: text/markdown&lt;/code&gt; header.&lt;/p&gt;
&lt;h2 id="why-this-is-interesting"&gt;Why this is interesting&lt;/h2&gt;
&lt;p&gt;AI is increasingly how people interact with the web, and AI responses are increasingly citing sources - meaning click-throughs from AI are becoming a meaningful traffic source, even if lower volume than traditional search.&lt;/p&gt;
&lt;p&gt;This creates a new SEO-adjacent consideration: if your content is easier for AI to parse, does it get cited more often? Hugely speculative, but then so was a lot of early SEO thinking.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also a practical bandwidth argument. AI crawlers can hammer a server, and if serving raw Markdown is cheaper to generate and transfer than a full HTML page - especially if Markdown is already your source format - that&amp;rsquo;s a worthwhile optimisation.&lt;/p&gt;
&lt;h2 id="but-chatgpt-cant-handle-textmarkdown"&gt;But ChatGPT can&amp;rsquo;t handle &lt;code&gt;text/markdown&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;I tried implementing a similar mechanism in a project I&amp;rsquo;m working on: serve raw Markdown if the client sent &lt;code&gt;Accept: text/markdown&lt;/code&gt;, or if the User Agent matched a known AI bot. Otherwise, serve normal HTML.&lt;/p&gt;
&lt;p&gt;I was pleased with the results: If nothing else it&amp;rsquo;s cool and fun to do. However, within a day the editorial team complained. They had asked ChatGPT to summarise an article on the site and got:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I couldn&amp;rsquo;t load the text of the exact article you linked directly, but based on the parts that are visible in search results and summaries of that piece […]&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I reverted the change, re-tested in a fresh conversation (ChatGPT caches responses and won&amp;rsquo;t re-fetch the same URL within a conversation), and it worked fine.&lt;/p&gt;
&lt;p&gt;Clearly ChatGPT just can&amp;rsquo;t handle markdown responses. Not what I wanted.&lt;/p&gt;
&lt;p&gt;I asked ChatGPT to explain the failure. The relevant part of its response (unbelievably it gave me a 5.5k character novel in response to a short question):&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;The browsing system I use does MIME-type gating before parsing. It expects:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;text/html&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;application/xhtml+xml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;sometimes &lt;code&gt;text/plain&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When it sees &lt;code&gt;Content-Type: text/markdown&lt;/code&gt; it typically [&amp;hellip;] rejects the response as non-renderable, aborts parsing [and] surfaces a generic &amp;ldquo;failed to fetch&amp;rdquo; error.&lt;/p&gt;
&lt;p&gt;This is not a limitation of &amp;ldquo;AI&amp;rdquo; in general. It&amp;rsquo;s a limitation of the controlled browsing sandbox&amp;rsquo;s MIME policy.&lt;/p&gt;
&lt;p&gt;If you pasted the Markdown directly into chat, I could read and parse it instantly. But when retrieving via HTTP, strict MIME validation applies.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="conclusion"&gt;Conclusion&lt;/h2&gt;
&lt;p&gt;The idea has merit - reduced bandwidth, simpler parsing, potential AI visibility benefits. But don&amp;rsquo;t rely on User Agent sniffing to decide who gets Markdown without first verifying that each bot on your list can actually handle a &lt;code&gt;text/markdown&lt;/code&gt; response. ChatGPT, at least for now, cannot.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116141605988327202"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Adding Plausible to Uptime Kuma Status Page</title><link>https://blog.thms.uk/2026/02/adding-plausible-to-uptime-kuma-status-page/</link><guid>https://blog.thms.uk/2026/02/adding-plausible-to-uptime-kuma-status-page/</guid><pubDate>Mon, 16 Feb 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/tools/">tools</category><category domain="https://blog.thms.uk/tag/meta/">meta</category><category domain="https://blog.thms.uk/tag/plausible/">plausible</category><description>Struggling to connect Plausible to Uptime Kuma’s status page? The required Analytics ID and Script URL aren’t obvious - here’s what they actually are.</description><enclosure url="https://media.thms.uk/images/2026-02-adding-plausible-to-uptime-kuma/header.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="adding-plausible-to-uptime-kuma-status-page"&gt;Adding Plausible to Uptime Kuma Status Page&lt;/h1&gt;
&lt;p&gt;Since version &lt;a href="https://github.com/louislam/uptime-kuma/releases/tag/2.1.0-beta.1"&gt;2.1&lt;/a&gt;, Uptime Kuma supports adding Plausible to its status pages. However, the setup is unintuitive and not documented: it asks for an &lt;strong&gt;Analytics ID&lt;/strong&gt; and an &lt;strong&gt;Analytics Script URL&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;These field names are generic, and when you create a site in Plausible, neither value is explicitly provided. Instead, Plausible gives you a single script snippet to copy and paste. That script contains what appears to be an ID (a random alphanumeric string prefixed with &lt;code&gt;pa-&lt;/code&gt;), but this is &lt;strong&gt;not&lt;/strong&gt; the Analytics ID that Uptime Kuma expects. In fact, none of the required values are shown directly in Plausible’s installation instructions.&lt;/p&gt;
&lt;p&gt;Having had &lt;a href="https://github.com/louislam/uptime-kuma/pull/5608/changes#diff-d5a6da1a06d408eda9c85ca7934a775254d78f068b252eea4ee898df1581ea5f"&gt;a look at the code&lt;/a&gt;, it becomes clear what Uptime Kuma actually expects:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Analytics ID&lt;/strong&gt;&lt;br&gt;
This is simply your &lt;strong&gt;status page domain&lt;/strong&gt;, for example &lt;code&gt;status.example.com&lt;/code&gt;. It must match the domain you configured when creating the site in Plausible.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Analytics Script URL&lt;/strong&gt;&lt;br&gt;
This is the legacy (pre–October 2025) script URL, which is the same for all sites. For Plausible Cloud, use &lt;code&gt;https://plausible.io/js/script.js&lt;/code&gt;. If you are running Plausible Community Edition, replace &lt;code&gt;plausible.io&lt;/code&gt; with your own Plausible instance domain.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Once these two values are entered into the Status Page configuration in Uptime Kuma, Plausible tracking should function correctly.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/116079574425341243"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Investigating the performance of Laravel's `whereIn` vs `whereIntegerInRaw`</title><link>https://blog.thms.uk/2026/01/laravel-wherein/</link><guid>https://blog.thms.uk/2026/01/laravel-wherein/</guid><pubDate>Thu, 08 Jan 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/php/">php</category><category domain="https://blog.thms.uk/tag/laravel/">laravel</category><description>Revisiting a classic Laravel tip: does `whereIntegerInRaw()` outperform `whereIn()` in 2026?</description><enclosure url="https://media.thms.uk/images/2026-01-laravel-wherein-vs-whererawintegerin.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="investigating-the-performance-of-laravels-wherein-vs-whereintegerinraw"&gt;Investigating the performance of Laravel&amp;rsquo;s &lt;code&gt;whereIn&lt;/code&gt; vs &lt;code&gt;whereIntegerInRaw&lt;/code&gt;&lt;/h1&gt;
&lt;p&gt;I recently came across &lt;a href="https://mstdn.social/@backpackforlaravel/115796650772319121"&gt;a post&lt;/a&gt; claiming that Laravel’s &lt;code&gt;-&amp;gt;whereIn()&lt;/code&gt; method is slow and that you should prefer &lt;code&gt;-&amp;gt;whereIntegerInRaw()&lt;/code&gt; when working with integer IDs.&lt;/p&gt;
&lt;p&gt;This claim appears to trace back to a &lt;a href="https://medium.com/@eelcoluurtsema/a-quick-fix-for-laravels-slow-wherein-7f00d5bb0d2d"&gt;2016 blog post&lt;/a&gt; where the author reported that a &lt;code&gt;-&amp;gt;whereIn()&lt;/code&gt; query with 10,000 IDs took ~4 seconds, and with 20,000 IDs ~15 seconds.&lt;/p&gt;
&lt;p&gt;Notably, that post did not include comparable timings for &lt;code&gt;-&amp;gt;whereIntegerInRaw()&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Historically, I believe the root cause was not Laravel itself, but an &lt;a href="https://bugs.php.net/bug.php?id=80027"&gt;old PDO bug&lt;/a&gt; related to parameter binding, which was fixed around 2020. Given that, I wanted to see whether the performance penalty still exists today - and if so, how significant it actually is.&lt;/p&gt;
&lt;h2 id="whats-the-difference-between-the-two"&gt;What&amp;rsquo;s the difference between the two?&lt;/h2&gt;
&lt;p&gt;Firstly, let&amp;rsquo;s look at the actual difference between the two:&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/laravel/framework/blob/edefe15d3cfb121448be1dcac44d65c926e54156/src/Illuminate/Database/Query/Builder.php#L1204C5-L1238C6"&gt;&lt;code&gt;-&amp;gt;whereIn()&lt;/code&gt;&lt;/a&gt; uses prepared statements, so the resulting query will look like this (assuming MySQL):&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;-- Step 1: Prepare the statement&lt;br&gt;PREPARE stmt FROM &amp;#39;SELECT * FROM models WHERE id IN (?,?)&amp;#39;;&lt;br&gt;SET @id1 = 1, @id2 = 2;&lt;br&gt;EXECUTE stmt USING @id1, @id2;&lt;br&gt;DEALLOCATE PREPARE stmt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;a href="https://github.com/laravel/framework/blob/edefe15d3cfb121448be1dcac44d65c926e54156/src/Illuminate/Database/Query/Builder.php#L1286"&gt;&lt;code&gt;-&amp;gt;whereIntegerInRaw()&lt;/code&gt;&lt;/a&gt; on the other hand casts every value in your array to &lt;code&gt;(int)&lt;/code&gt; before simply adding them to the statement, so the resulting query will be a lot simpler and look like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;SELECT * &lt;br&gt;FROM models&lt;br&gt;WHERE id IN (1,2)&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="testing"&gt;Testing&lt;/h2&gt;
&lt;p&gt;I wanted to answer two questions:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Is there still a measurable performance difference between &lt;code&gt;-&amp;gt;whereIn()&lt;/code&gt; and &lt;code&gt;-&amp;gt;whereIntegerInRaw()&lt;/code&gt; in 2026?&lt;/li&gt;
&lt;li&gt;If so, does the difference still scale non-linearly as the input size grows?&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;To test this, I wrote a small benchmark that queries between 1,000 and 50,000 IDs using both methods and compares execution time.&lt;/p&gt;
&lt;p&gt;To isolate query performance, I used &lt;code&gt;-&amp;gt;count()&lt;/code&gt; instead of &lt;code&gt;-&amp;gt;get()&lt;/code&gt; to avoid the overhead of hydrating large collections, which would otherwise dominate the results.&lt;/p&gt;
&lt;p&gt;Here’s the script:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;foreach (&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;1_000,&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;10_000,&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;20_000,&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;50_000,&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;]&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;as $number&lt;br&gt;) {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;echo &amp;#34;Number of Records: &amp;#34;.number_format($number).PHP_EOL;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;$start = hrtime(true);&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Model::query()&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;gt;whereIn(&amp;#39;id&amp;#39;, range(1, $number))&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;gt;count();&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;$durationWhereIn = hrtime(true) - $start;&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;printf(&amp;#39;Duration: %s ms (whereIn)%s&amp;#39;,&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;number_format($durationWhereIn / 1_000_000, 2),&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;PHP_EOL&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;);&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;$start = hrtime(true);&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Model::query()&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;gt;whereIntegerInRaw(&amp;#39;id&amp;#39;, range(1, $number))&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;gt;count();&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;$durationRaw = hrtime(true) - $start;&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;printf(&amp;#39;Duration: %s ms (whereIntegerInRaw)%s&amp;#39;,&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;number_format($durationRaw / 1_000_000, 2),&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;PHP_EOL&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;);&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;printf(&amp;#39;Time difference: %s ms / %s%%%s&amp;#39;,&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;number_format(($durationWhereIn - $durationRaw) / 1_000_000, 2),&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;round(($durationWhereIn - $durationRaw) / $durationWhereIn * 100, 2),&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;PHP_EOL.PHP_EOL,&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;);&lt;br&gt;}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;And the output:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Number of Records: 1,000
Duration: 67.10 ms (whereIn)
Duration: 4.06 ms (whereIntegerInRaw)
Time difference: 63.04 ms / 93.95%
Number of Records: 10,000
Duration: 109.93 ms (whereIn)
Duration: 9.54 ms (whereIntegerInRaw)
Time difference: 100.39 ms / 91.32%
Number of Records: 20,000
Duration: 134.75 ms (whereIn)
Duration: 18.03 ms (whereIntegerInRaw)
Time difference: 116.73 ms / 86.62%
Number of Records: 50,000
Duration: 226.52 ms (whereIn)
Duration: 41.59 ms (whereIntegerInRaw)
Time difference: 184.93 ms / 81.64%
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="takeaways"&gt;Takeaways&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Yes, &lt;code&gt;-&amp;gt;whereIn()&lt;/code&gt; is slower than &lt;code&gt;-&amp;gt;whereIntegerInRaw()&lt;/code&gt;.&lt;/strong&gt;
This is expected: &lt;code&gt;-&amp;gt;whereIn()&lt;/code&gt; relies on parameter binding, which has non-zero overhead. That said, it’s still surprising to see differences approaching an order of magnitude in relative terms.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The absolute difference is small.&lt;/strong&gt;
Even at 50,000 IDs, the gap is ~185 ms. In most real-world applications, this is unlikely to be the dominant bottleneck.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The relative difference decreases as the input grows.&lt;/strong&gt;
While the percentage difference is large for small sets, it steadily declines as the number of IDs increases. For very large &lt;code&gt;IN&lt;/code&gt; clauses, the overhead of binding becomes less significant relative to the rest of the query execution. This is the opposite of the observation from 2016.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;In 2026, switching to &lt;code&gt;-&amp;gt;whereIntegerInRaw()&lt;/code&gt; is probably no longer worth it.&lt;/strong&gt;
I feel that the readability and simplicity of &lt;code&gt;-&amp;gt;whereIn()&lt;/code&gt; generally outweigh the modest performance gain.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;As always: benchmark in your own environment. Your database, driver, schema, and workload will matter far more than any micro-optimization shown here.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/115859530675907007"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Adding Plausible to Mastodon Using nginx `sub_filter`</title><link>https://blog.thms.uk/2026/01/adding-plausible-to-mastodon/</link><guid>https://blog.thms.uk/2026/01/adding-plausible-to-mastodon/</guid><pubDate>Mon, 05 Jan 2026 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/tools/">tools</category><category domain="https://blog.thms.uk/tag/meta/">meta</category><category domain="https://blog.thms.uk/tag/plausible/">plausible</category><category domain="https://blog.thms.uk/tag/mastodon/">mastodon</category><description>Injecting Plausible Analytics into Mastodon using nginx sub_filter, CSP header overrides, and inline script hashes - plus why doing this is probably a bad idea.</description><enclosure url="https://media.thms.uk/images/2026-01-adding-plausible-to-mastodon.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="adding-plausible-to-mastodon-using-nginx-sub_filter"&gt;Adding Plausible to Mastodon Using nginx &lt;code&gt;sub_filter&lt;/code&gt;&lt;/h1&gt;
&lt;p&gt;I’m running an instance of &lt;a href="https://blog.thms.uk/2025/01/plausible"&gt;Plausible Analytics Community Edition&lt;/a&gt; on my VPS, mostly so I can see how many people read my blog.&lt;/p&gt;
&lt;p&gt;I don’t actually need analytics - I have no plans to monetise the site - but it’s interesting to see what people engage with, and self-hosting Plausible was an itch I wanted to scratch anyway.&lt;/p&gt;
&lt;p&gt;In this post I’ll explain how I also injected Plausible Analytics into my Mastodon instance’s web UI, and then explain why you probably shouldn’t do this yourself 😁&lt;/p&gt;
&lt;h2 id="set-up-plausible-analytics"&gt;Set up Plausible Analytics&lt;/h2&gt;
&lt;p&gt;Start by creating a new site in Plausible and copying the tracking code it gives you.&lt;/p&gt;
&lt;p&gt;It’ll look something like this:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;!-- Privacy-friendly analytics by Plausible --&amp;gt;&lt;br&gt;&amp;lt;script async src=&amp;#34;https://plausible.example/js/pa-abcdef.js&amp;#34;&amp;gt;&amp;lt;/script&amp;gt;&lt;br&gt;&amp;lt;script&amp;gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};&lt;br&gt;&amp;nbsp;&amp;nbsp;plausible.init()&lt;br&gt;&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The first thing we’ll do is remove comments and unnecessary whitespace so the whole thing fits on a single line. This is purely because nginx’s &lt;code&gt;sub_filter&lt;/code&gt; is much easier to work with that way:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&amp;lt;script async src=&amp;#34;https://plausible.example/js/pa-abcdef.js&amp;#34;&amp;gt;&amp;lt;/script&amp;gt;&amp;lt;script&amp;gt;window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init()&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Keep this handy - you’ll need it in the next step.&lt;/p&gt;
&lt;h2 id="configure-sub_filter"&gt;Configure &lt;code&gt;sub_filter&lt;/code&gt;&lt;/h2&gt;
&lt;p&gt;On your Mastodon server, open your nginx configuration file (for me this lives at &lt;code&gt;/etc/nginx/sites-enabled/mastodon&lt;/code&gt;) and find the &lt;code&gt;@proxy&lt;/code&gt; location block.&lt;/p&gt;
&lt;p&gt;We’ll use &lt;code&gt;sub_filter&lt;/code&gt; to replace the closing &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt; tag with our Plausible script followed by &lt;code&gt;&amp;lt;/body&amp;gt;&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[...]&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;location @proxy {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[...]&lt;br&gt;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sub_filter &amp;#39;&amp;lt;/body&amp;gt;&amp;#39; &amp;#39;{your tracking script here}&amp;lt;/body&amp;gt;&amp;#39;;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;sub_filter_once on;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}&lt;br&gt;}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Replace &lt;code&gt;{your tracking script here}&lt;/code&gt; with the single-line Plausible snippet from above.&lt;/p&gt;
&lt;p&gt;Then validate and reload nginx:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nginx -t&lt;br&gt;systemctl reload nginx&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="fix-content-security-policy-headers"&gt;Fix Content Security Policy headers&lt;/h2&gt;
&lt;p&gt;If you now load your Mastodon instance in a browser, you’ll see two errors in the console:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Loading the script &amp;#39;https://plausible.example/js/pa-abcdef.js&amp;#39; violates the following Content Security Policy directive: &amp;#34;script-src &amp;#39;self&amp;#39; https://mastodon.example &amp;#39;wasm-unsafe-eval&amp;#39;&amp;#34;. Note that &amp;#39;script-src-elem&amp;#39; was not explicitly set, so &amp;#39;script-src&amp;#39; is used as a fallback. The action has been blocked.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Executing inline script violates the following Content Security Policy directive &amp;#39;script-src &amp;#39;self&amp;#39; https://mastodon.example &amp;#39;wasm-unsafe-eval&amp;#39;&amp;#39;. Either the &amp;#39;unsafe-inline&amp;#39; keyword, a hash (&amp;#39;sha256-Ui4JgH0e3Hy16saR0evkbiiRF3sLnhJ6gTgTVWOYDFI=&amp;#39;), or a nonce (&amp;#39;nonce-...&amp;#39;) is required to enable inline execution. The action has been blocked.
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This is expected. Mastodon’s &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP"&gt;Content Security Policy&lt;/a&gt; correctly blocks both the external Plausible script and the injected inline JavaScript.&lt;/p&gt;
&lt;p&gt;To fix this, we need to extend the existing CSP.&lt;/p&gt;
&lt;h3 id="extract-the-current-csp"&gt;Extract the current CSP&lt;/h3&gt;
&lt;p&gt;First, grab the CSP header your instance currently sends. There are several ways to do this; one easy option is to visit &lt;a href="https://securityheaders.com"&gt;securityheaders.com&lt;/a&gt; and enter your Mastodon URL.&lt;/p&gt;
&lt;p&gt;Scroll down to &lt;strong&gt;Raw Headers&lt;/strong&gt; and copy the &lt;code&gt;content-security-policy&lt;/code&gt; header into your editor.&lt;/p&gt;
&lt;p&gt;&lt;img src="https://media.thms.uk/images/2026-01-plausible-mastodon/raw-headers.png?v=53829430ab26d33f369e6165e83c86db" alt="Raw Headers"&gt;&lt;/p&gt;
&lt;h3 id="update-connect-src"&gt;Update connect-src&lt;/h3&gt;
&lt;p&gt;Find the &lt;code&gt;connect-src&lt;/code&gt; directive and add your Plausible domain:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;connect-src &amp;#39;self&amp;#39; data: blob: https://mastodon.example https://media.mastodon.example wss://mastodon.example https://plausible.example;
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="update-script-src"&gt;Update script-src&lt;/h3&gt;
&lt;p&gt;Next, update the &lt;code&gt;script-src&lt;/code&gt; directive. You need to add:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Your Plausible domain&lt;/li&gt;
&lt;li&gt;The SHA-256 hash shown in the browser error for the inline script
(replace the example hash below with your own)&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Example:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;script-src &amp;#39;self&amp;#39; https://mastodon.example &amp;#39;wasm-unsafe-eval&amp;#39; https://plausible.example &amp;#39;sha256-Ui4JgH0e3Hy16saR0evkbiiRF3sLnhJ6gTgTVWOYDFI=&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="example-full-csp"&gt;Example full CSP&lt;/h3&gt;
&lt;p&gt;Putting it all together, your final header might look something like this:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;base-uri &amp;#39;none&amp;#39;; default-src &amp;#39;none&amp;#39;; frame-ancestors &amp;#39;none&amp;#39;; font-src &amp;#39;self&amp;#39; https://mastodon.example; img-src &amp;#39;self&amp;#39; data: blob: https://mastodon.example https://media.mastodon.example; media-src &amp;#39;self&amp;#39; data: https://mastodon.example https://media.mastodon.example; manifest-src &amp;#39;self&amp;#39; https://mastodon.example; form-action &amp;#39;none&amp;#39;; child-src &amp;#39;self&amp;#39; blob: https://mastodon.example; worker-src &amp;#39;self&amp;#39; blob: https://mastodon.example; connect-src &amp;#39;self&amp;#39; data: blob: https://stati.stic.dev https://mastodon.example https://media.mastodon.example wss://mastodon.example https://plausible.example; script-src &amp;#39;self&amp;#39; https://mastodon.example https://stati.stic.dev &amp;#39;wasm-unsafe-eval&amp;#39; https://plausible.example &amp;#39;sha256-Ui4JgH0e3Hy16saR0evkbiiRF3sLnhJ6gTgTVWOYDFI=&amp;#39;; frame-src &amp;#39;self&amp;#39; https:; style-src &amp;#39;self&amp;#39; https://mastodon.example &amp;#39;nonce-d8e4i1dmKEMCD9Wl6x+Usw==&amp;#39;
&lt;/code&gt;&lt;/pre&gt;&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Your CSP will differ from mine. Differences may include:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Different domains&lt;/li&gt;
&lt;li&gt;No separate media domain&lt;/li&gt;
&lt;li&gt;Different nonces (or none at all)&lt;/li&gt;
&lt;li&gt;A different SHA hash for the inline script&lt;/li&gt;
&lt;li&gt;Plenty of other small variations&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Do not copy this header verbatim&lt;/strong&gt;. Don&amp;rsquo;t even use it as a starting point. Use your own CSP as a base and apply only the changes described above.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="apply-the-new-csp-in-nginx"&gt;Apply the new CSP in nginx&lt;/h3&gt;
&lt;p&gt;Open your nginx config again and return to the &lt;code&gt;@proxy&lt;/code&gt; location.&lt;/p&gt;
&lt;p&gt;Add two directives: one to remove Mastodon’s CSP header, and one to add your modified version:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;server {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[...]&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;location @proxy {&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;[...]&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;proxy_hide_header Content-Security-Policy;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;add_header Content-Security-Policy &amp;#34;{your header here}&amp;#34; always;&lt;br&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;}&lt;br&gt;}&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Replace &lt;code&gt;{your header here}&lt;/code&gt; with your full CSP string. Make sure it’s wrapped in double quotes, uses single quotes internally, and ends with the unquoted always keyword.&lt;/p&gt;
&lt;p&gt;Finally, validate your config file,&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;nginx -t&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;and if everything is OK, reload nginx:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;systemctl reload nginx&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;If everything worked, the console errors should be gone and you should start seeing events in Plausible.&lt;/p&gt;
&lt;h2 id="why-you-shouldnt-install-plausible-on-your-mastodon-instance"&gt;Why you shouldn’t install Plausible on your Mastodon instance&lt;/h2&gt;
&lt;p&gt;I did this mostly out of curiosity and as a learning exercise. Here are a few reasons - in no particular order - why you probably shouldn’t:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;It violates fediverse norms.&lt;/strong&gt; Mastodon and the fediverse strongly lean towards a no-tracking social contract. On a single-user instance this mostly affects me, but on a multi-user instance this would be a serious breach of trust.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The data is incomplete.&lt;/strong&gt; Traffic from mobile and desktop apps isn’t tracked at all - only users accessing the web UI.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;The data is &lt;em&gt;really&lt;/em&gt; incomplete.&lt;/strong&gt; Federated traffic is entirely invisible. For a single-user instance especially, the vast majority of readers will never hit your web UI.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;CSP changes are brittle.&lt;/strong&gt; Editing Content Security Policy headers is fiddly and error-prone. If Mastodon updates its CSP, you’ll need to manually track and merge changes - and you’ll probably only notice when something breaks.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;So let me repeat: you probably shouldn’t do this. At best it’s a mostly pointless exercise; at worst it undermines user trust.&lt;/p&gt;
&lt;p&gt;That said, I wanted to know whether it was possible to add Plausible to Mastodon’s web interface. The answer is yes - and it’s not particularly hard.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/115843605722452549"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Using Pi-hole as a DNS Server for my Tailnet</title><link>https://blog.thms.uk/2025/12/pihole-for-tailnet/</link><guid>https://blog.thms.uk/2025/12/pihole-for-tailnet/</guid><pubDate>Tue, 16 Dec 2025 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/pihole/">pihole</category><category domain="https://blog.thms.uk/tag/tools/">tools</category><category domain="https://blog.thms.uk/tag/tailscale/">tailscale</category><description>Use Pi-hole as a DNS server across your entire Tailnet with Tailscale, without forcing all traffic through an exit node. Ad blocking and internal DNS everywhere.</description><enclosure url="https://media.thms.uk/images/2025-11-pi-hole-for-tailscale.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="using-pi-hole-as-a-dns-server-for-my-tailnet"&gt;Using Pi-hole as a DNS Server for my Tailnet&lt;/h1&gt;
&lt;p&gt;After recently setting up &lt;a href="https://pi-hole.net"&gt;Pi-hole&lt;/a&gt; as my home DNS server and network-wide ad blocker, I wanted to be able to use it when I’m away from home on mobile data as well. I also wanted all of my servers (such as &lt;a href="https://blog.thms.uk/2023/01/setting-up-mastodon"&gt;my Mastodon instance&lt;/a&gt;) to use the same DNS infrastructure.&lt;/p&gt;
&lt;p&gt;I already use &lt;a href="https://tailscale.com"&gt;Tailscale&lt;/a&gt; across my devices. My Raspberry Pi, which runs Pi-hole, is configured as an exit node for situations where I’m on dodgy Wi-Fi. However, I don’t want to route &lt;em&gt;all&lt;/em&gt; traffic from all devices through my home network. Exit nodes are inherently slower: they’re limited by upstream bandwidth and by the CPU cost of decrypting and re-encrypting every packet.&lt;/p&gt;
&lt;p&gt;Fortunately, with a small amount of additional configuration, it’s possible to make every device in your Tailnet use Pi-hole &lt;em&gt;only&lt;/em&gt; for DNS, without routing general traffic through an exit node. Here’s how.&lt;/p&gt;
&lt;h2 id="configure-pi-hole-to-accept-tailnet-traffic"&gt;Configure Pi-hole to accept Tailnet traffic&lt;/h2&gt;
&lt;p&gt;First, Pi-hole needs to accept DNS queries originating from your Tailnet. By default, Pi-hole only accepts connections from local devices, which is the correct and safe default, as you don&amp;rsquo;t want to open your Pi-hole to the internet.&lt;/p&gt;
&lt;p&gt;However, if your firewall rules are sane and you understand the implications, you can relax this restriction. In the Pi-hole admin UI, go to &lt;strong&gt;Settings&lt;/strong&gt; → &lt;strong&gt;DNS&lt;/strong&gt; → &lt;strong&gt;Interface settings&lt;/strong&gt; and select &lt;strong&gt;Permit all origins&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Before enabling this, make sure you read the &lt;a href="https://docs.pi-hole.net/ftldns/interfaces/"&gt;official documentation&lt;/a&gt; so you understand exactly what this option does and when it’s appropriate.&lt;/p&gt;
&lt;h2 id="install-tailscale-on-the-pi-hole-host"&gt;Install Tailscale on the Pi-hole host&lt;/h2&gt;
&lt;p&gt;Tailscale has excellent &lt;a href="https://tailscale.com/download/linux"&gt;installation documentation&lt;/a&gt;, so I won’t repeat it here.&lt;/p&gt;
&lt;p&gt;If you’re feeling lazy, don’t mind running a remote install script directly from the internet, and generally live the YOLO lifestyle, you can install it with:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;curl -fsSL https://tailscale.com/install.sh | sh&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Otherwise, follow the manual installation steps in the docs.&lt;/p&gt;
&lt;h2 id="configure-tailscale-dns-to-use-pi-hole"&gt;Configure Tailscale DNS to use Pi-hole&lt;/h2&gt;
&lt;p&gt;Next, tell Tailscale to use your Pi-hole instance as a DNS resolver for the entire Tailnet.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Open the &lt;a href="https://login.tailscale.com/admin/dns"&gt;Tailscale Admin Dashboard&lt;/a&gt; and navigate to the &lt;strong&gt;DNS&lt;/strong&gt; tab.&lt;/li&gt;
&lt;li&gt;Scroll to &lt;strong&gt;Nameservers&lt;/strong&gt; and click &lt;strong&gt;Add nameserver&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;Custom&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enter your Pi-hole’s &lt;em&gt;Tailscale IP address&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Optionally enable &lt;strong&gt;Use with exit node&lt;/strong&gt; if you also want DNS to work when an exit node is active.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Save&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;At this point, most devices should automatically start using Pi-hole for DNS resolution.&lt;/p&gt;
&lt;h2 id="accept-dns-and-subnet-routes-on-linux-clients"&gt;Accept DNS and subnet routes on Linux clients&lt;/h2&gt;
&lt;p&gt;In practice, I found that some Linux machines picked up the new DNS configuration immediately, while others didn’t. I haven’t dug deeply into why this happens, but the fix was simple.&lt;/p&gt;
&lt;p&gt;On the affected machines, explicitly tell Tailscale to accept both DNS settings and subnet routes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;tailscale set --accept-routes&lt;br&gt;tailscale set --accept-dns&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After running these commands, DNS resolution started flowing through Pi-hole as expected.&lt;/p&gt;
&lt;h2 id="result"&gt;Result&lt;/h2&gt;
&lt;p&gt;That’s really all there is to it. Every device in my Tailnet now uses Pi-hole for DNS, regardless of location, without forcing all traffic through an exit node. This gives me ad blocking, internal name resolution, and consistent DNS behaviour everywhere, while keeping latency and bandwidth usage to a minimum.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/115728711493651101"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Configuring a Custom Block Page for Pi-hole</title><link>https://blog.thms.uk/2025/11/pi-hole-custom-block-page/</link><guid>https://blog.thms.uk/2025/11/pi-hole-custom-block-page/</guid><pubDate>Mon, 17 Nov 2025 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/pihole/">pihole</category><category domain="https://blog.thms.uk/tag/tools/">tools</category><description>Set up a custom Pi-hole block page so users know when a domain is being filtered. It’s easy to enable and provides helpful context for anyone on your network.</description><enclosure url="https://media.thms.uk/images/2025-11-custom-block-page-pi-hole/image.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="configuring-a-custom-block-page-for-pi-hole"&gt;Configuring a Custom Block Page for Pi-hole&lt;/h1&gt;
&lt;p&gt;I recently installed Pi-hole on my home network. I share my home with a few people, not all of whom are particularly tech-savvy. So I wanted a block page to appear whenever a domain is blocked, helping users understand what’s happening and what to do if they want a domain unblocked.&lt;/p&gt;
&lt;p&gt;Pi-hole used to support a custom block page, but the feature was removed a few years ago because widespread HTTPS adoption made it less useful. They’re not wrong: a custom block page served by Pi-hole will trigger a certificate warning before users see the page.&lt;/p&gt;
&lt;p&gt;I accept this limitation - but, as a Brit, I’ll counter that “every little helps,” and it’s still better than nothing. Enabling a custom block page is actually quite straightforward, and I wanted to write down the steps here for my own notes.&lt;/p&gt;
&lt;h2 id="create-the-block-page-html"&gt;Create the Block Page HTML&lt;/h2&gt;
&lt;p&gt;I cheated a little and asked Claude AI to generate the HTML for me. You can view the source at &lt;a href="https://gist.thms.uk/michael/pi-hole-block-page"&gt;gist.thms.uk/michael/pi-hole-block-page&lt;/a&gt;.&lt;br&gt;
It’s straightforward, and you can adjust it as needed - for example, you probably don’t want your users to “Contact Michael” if they have questions.&lt;/p&gt;
&lt;p&gt;Here’s what it looks like. It’s loosely based on Pi-hole’s default theme and colours:&lt;/p&gt;
&lt;p&gt;&lt;img src="https://media.thms.uk/images/2025-11-custom-block-page-pi-hole/custom-block-page.png?v=e9a8a4e5f6eb6118b4bd694238789e54" alt="My custom block page"&gt;&lt;/p&gt;
&lt;p&gt;You will need to place two copies of this file on your server:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;One copy needs to go to &lt;code&gt;/var/www/html/index.html&lt;/code&gt;. This file will be served if a user visits the root domain.&lt;/li&gt;
&lt;li&gt;One copy needs to go to &lt;code&gt;/var/www/html/error404.html&lt;/code&gt;. This file will be served if a user visits any other URL on the blocked domain.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Make sure both files are readable by the &lt;code&gt;pihole&lt;/code&gt; user.&lt;/p&gt;
&lt;h2 id="enable-the-custom-block-page"&gt;Enable the Custom Block Page&lt;/h2&gt;
&lt;p&gt;In order to enable the block page you need to change the DNS blockign mode:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;SSH into your server and open &lt;code&gt;/etc/pihole/pihole.toml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Search for &lt;code&gt;[dns.blocking]&lt;/code&gt; and find the &lt;code&gt;mode&lt;/code&gt; setting. Change it to:&lt;br&gt;
&lt;code&gt;mode = &amp;quot;IP&amp;quot;&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Search for &lt;code&gt;[webserver]&lt;/code&gt; and find the &lt;code&gt;advancedOpts&lt;/code&gt; settings. Change it to
&lt;code&gt;advancedOpts = [ &amp;quot;error_pages=/var/www/html/&amp;quot; ]&lt;/code&gt;
(if you already have any values in there, make sure you don&amp;rsquo;t remove them, but rather append to the array of options.)&lt;/li&gt;
&lt;li&gt;Restart Pi-hole:&lt;br&gt;
&lt;code&gt;sudo service pihole-FTL restart&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;And that’s it. You may want to visit a blocked domain (such as &lt;code&gt;http://adclick.g.doubleclick.net&lt;/code&gt;) to confirm that it’s working.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/115566116241248245"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Seeing the Full Conversation: Fetching Replies on Mastodon</title><link>https://blog.thms.uk/2025/07/fetching-replies-mastodon/</link><guid>https://blog.thms.uk/2025/07/fetching-replies-mastodon/</guid><pubDate>Mon, 21 Jul 2025 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/mastodon/">mastodon</category><description>Mastodon doesn't always show replies to posts from other instances. This post explores several ways to improve reply visibility and make conversations feel more complete.</description><enclosure url="https://media.thms.uk/images/2025-07-mastodon-fetch-replies.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="seeing-the-full-conversation-fetching-replies-on-mastodon"&gt;Seeing the Full Conversation: Fetching Replies on Mastodon&lt;/h1&gt;
&lt;p&gt;One of Mastodon&amp;rsquo;s biggest shortcomings is how it handles fetching replies to posts. This isn’t unique to Mastodon (it’s a limitation shared across much of the fediverse) but since I access the fediverse through Mastodon, that’s what I’ll focus on here.&lt;/p&gt;
&lt;p&gt;The issue arises when I come across a post from a user on a different instance than my own. Since I run a single-user instance, &lt;em&gt;every&lt;/em&gt; post I see is from another instance - and without taking extra steps, I can’t see the replies.&lt;/p&gt;
&lt;p&gt;This happens because Mastodon primarily fetches content from users (and hashtags) that you - or someone else on your server - already follow. (This is a simplification, but it captures the core idea.)&lt;/p&gt;
&lt;p&gt;This limitation has a few negative side effects:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;It kills the flow of conversations.&lt;/li&gt;
&lt;li&gt;It leads to reply duplication - I might unknowingly post the same reply as someone else, which can be frustrating for the original poster.&lt;/li&gt;
&lt;li&gt;It makes Mastodon feel more fragmented than it needs to be.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;In this post, I’ll outline a few ways you can work around this and see more replies to posts on your Mastodon instance.&lt;/p&gt;
&lt;h2 id="1-viewing-posts-on-their-original-instance"&gt;1. Viewing Posts on Their Original Instance&lt;/h2&gt;
&lt;p&gt;This is the most straightforward and reliable method. Mastodon’s web interface has an “Open original page” option for any post, which opens the post on the author’s own instance. You’ll then be able to see all the replies there.&lt;/p&gt;
&lt;p&gt;Most third-party apps offer similar functionality.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Always works&lt;/li&gt;
&lt;li&gt;Requires no setup&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Cumbersome to constantly open external links&lt;/li&gt;
&lt;li&gt;Breaks the “stay in one app” experience&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Still, if you just want to catch up on a conversation, this method is the only bulletproof one.&lt;/p&gt;
&lt;h2 id="2-using-fedifetcher"&gt;2. Using FediFetcher&lt;/h2&gt;
&lt;p&gt;I wrote this tool myself, so take this with a grain of salt - but I genuinely find it invaluable.&lt;/p&gt;
&lt;p&gt;&lt;a href="https://blog.thms.uk/fedifetcher"&gt;FediFetcher&lt;/a&gt; scans your home feed and proactively pulls in missing replies, injecting them into your instance. That way, when you open posts in your own timeline, the replies are already there. (FediFetcher can do a lot more, but that&amp;rsquo;s for another time.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Makes conversations more complete&lt;/li&gt;
&lt;li&gt;Keeps you within your own Mastodon instance&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Doesn’t work with all remote servers (e.g., GoToSocial, Threads)&lt;/li&gt;
&lt;li&gt;Requires setting up and running external software&lt;/li&gt;
&lt;li&gt;Increases load on your server and others, as it scans many posts regularly&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you’re technically inclined and willing to run a small background service, this is a powerful option.&lt;/p&gt;
&lt;h2 id="3-using-a-browser-extension"&gt;3. Using a Browser Extension&lt;/h2&gt;
&lt;p&gt;&lt;a href="https://substitoot.kludge.guru/"&gt;Substitoot&lt;/a&gt; is a browser extension (for Chrome, Firefox, and compatible browsers) that fetches remote replies when you open a post.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Easy to install and use&lt;/li&gt;
&lt;li&gt;Works in real time as you browse&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Only works in supported browsers&lt;/li&gt;
&lt;li&gt;Doesn’t help if you use mobile apps or unsupported browsers (like Safari)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If you primarily use Mastodon through a desktop browser, this can be a simple, no-fuss solution.&lt;/p&gt;
&lt;h2 id="4-letting-mastodon-handle-it"&gt;4. Letting Mastodon Handle It&lt;/h2&gt;
&lt;p&gt;As of version 4.4, Mastodon includes an experimental option to fetch replies automatically. Admins can enable this in the instance’s configuration (via the &lt;code&gt;.env.production&lt;/code&gt; file - see &lt;a href="https://docs.joinmastodon.org/admin/config/#fetch-all-replies"&gt;Fetch All Replies Documentation&lt;/a&gt; for details).&lt;/p&gt;
&lt;p&gt;Unfortunately, this feature is opt-in, and there’s no way for end users to tell if their instance has it enabled, aside from asking their admin.&lt;/p&gt;
&lt;p&gt;Maybe most importantly it really isn&amp;rsquo;t something that users can control: Their instance either has it enabled or not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Built-in, efficient, and seamless for users&lt;/li&gt;
&lt;li&gt;No third-party tools required&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;Cons:&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Not enabled by default&lt;/li&gt;
&lt;li&gt;Some admins may worry about performance or storage overhead&lt;/li&gt;
&lt;li&gt;Lack of visibility or control for end users&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Long-term, I think this is the most sustainable and user-friendly solution. But awareness and adoption are currently lacking.&lt;/p&gt;
&lt;h2 id="in-summary"&gt;In Summary&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Open original page&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reliable, no setup&lt;/td&gt;
&lt;td&gt;Interrupts workflow&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;FediFetcher&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Powerful, automated&lt;/td&gt;
&lt;td&gt;Needs setup, increases load, won&amp;rsquo;t work for all posts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Substitoot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Easy for browser users&lt;/td&gt;
&lt;td&gt;Limited to desktop browsers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Mastodon 4.4+ reply fetch&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Seamless, efficient&lt;/td&gt;
&lt;td&gt;Opt-in by server admin, cannot be enabled by &amp;rsquo;normal&amp;rsquo; users&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Until Mastodon adopts a more unified and user-centric approach to fetching replies, these workarounds can help fill the gap. Hopefully, as more users and admins understand the issue, we’ll see better adoption and wider availability of replies.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/114891709688902379"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>Upgrading Mastodon from PostgreSQL 15 to PostgreSQL 17</title><link>https://blog.thms.uk/2025/06/upgrade-postgres-15-17-mastodon/</link><guid>https://blog.thms.uk/2025/06/upgrade-postgres-15-17-mastodon/</guid><pubDate>Fri, 06 Jun 2025 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/mastodon/">mastodon</category><category domain="https://blog.thms.uk/tag/sql/">sql</category><category domain="https://blog.thms.uk/tag/postgres/">postgres</category><description>Upgrading Mastodon’s database from PostgreSQL 15 to 17 on Ubuntu 24.04 is fast and easy using `pg_upgradecluster` with link mode. Here’s a quick guide with steps, tips, and cleanup.</description><enclosure url="https://media.thms.uk/images/2025-06-upgrade-psql-mastodon.png" type="image/png" length="0"/><content:encoded>&lt;h1 id="upgrading-mastodon-from-postgresql-15-to-postgresql-17"&gt;Upgrading Mastodon from PostgreSQL 15 to PostgreSQL 17&lt;/h1&gt;
&lt;p&gt;I recently upgraded my Mastodon Ubuntu host from Ubuntu 22.04 to 24.04 and noticed that this comes with PostgreSQL 17 installed side-by-side with version 15.&lt;/p&gt;
&lt;p&gt;So I thought I&amp;rsquo;d actually make use of PostgreSQL 17, since I already got it installed.&lt;/p&gt;
&lt;h2 id="backup"&gt;Backup!&lt;/h2&gt;
&lt;p&gt;Firstly, take a backup. Always a good idea. In my case I took a snapshot through the Hetzner console, because &lt;a href="https://blog.thms.uk/2023/01/setting-up-mastodon#server-setup-and-system-requirements"&gt;I host my instance on a Hetzner VPS&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="upgrade-postgresql-15-to-17"&gt;Upgrade PostgreSQL 15 to 17&lt;/h2&gt;
&lt;p&gt;We start the update by stopping all mastodon services, to ensure that there are no writes to the DB during the process, and thus reduce the risk of errors:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;systemctl stop mastodon-sidekiq&lt;br&gt;systemctl stop mastodon-streaming&lt;br&gt;systemctl stop mastodon-web&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;In my case the PostgreSQL 17 cluster was already running, so I first had to stop it. You can confirm this by looking at the output of &lt;code&gt;pg_lsclusters&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ pg_lsclusters&lt;br&gt;Ver Cluster Port Status Owner Data directory Log file&lt;br&gt;15 main 5432 online postgres /var/lib/postgresql/15/main /var/log/postgresql/postgresql-15-main.log&lt;br&gt;17 main 5433 online postgres /var/lib/postgresql/17/main /var/log/postgresql/postgresql-17-main.log&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;As you can see both versions 15 and 17 are running, so lets stop version 17:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pg_dropcluster 17 main --stop&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Confirm that only the PostgreSQL 15 cluster is now running:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ pg_lsclusters&lt;br&gt;Ver Cluster Port Status Owner Data directory Log file&lt;br&gt;15 main 5432 online postgres /var/lib/postgresql/15/main /var/log/postgresql/postgresql-15-main.log&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Finally, it&amp;rsquo;s time for the actual upgrade. PostgreSQL offers the &lt;a href="https://www.postgresql.org/docs/current/pgupgrade.html"&gt;&lt;code&gt;pg_upgrade&lt;/code&gt; tool&lt;/a&gt;, which on Ubuntu is wrapped in &lt;a href="https://manpages.ubuntu.com/manpages/noble/en/man1/pg_upgradecluster.1.html"&gt;&lt;code&gt;pg_upgradecluster&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;By default this will &lt;code&gt;pg_dump&lt;/code&gt; your database to disk from version 15, then &lt;code&gt;pg_restore&lt;/code&gt; it into version 17. That means you need a lot fo free disk space, which I don&amp;rsquo;t have on my system. (It&amp;rsquo;s also pretty slow,but that was less of a concern for me.) Thankfully &lt;code&gt;pg_upgradecluster&lt;/code&gt; gives us the option to upgrade the existing files and simply place a hard link. To quote from the docs:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;If you use link mode, the upgrade will be much faster (no file copying) and use less disk space, but you will not be able to access your old cluster once you start the new cluster after the upgrade. Link mode also requires that the old and new cluster data directories be in the same file system. (Tablespaces and pg_wal can be on different file systems.)&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I&amp;rsquo;m fine with these drawback, so I&amp;rsquo;m using link mode. The command to run in order to upgrade using link mode is:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pg_upgradecluster -m link 15 main&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This took about a minute in my case, so it was very quick. This will crucially also copy your database config files, so it will make sure that the PostgreSQL 17 cluster is now running on the same port as the version 15 cluster was running previously. This can again by confirmed by running &lt;code&gt;pg_lsclusters&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;$ pg_lsclusters&lt;br&gt;Ver Cluster Port Status Owner Data directory Log file&lt;br&gt;15 main 5433 online postgres /var/lib/postgresql/15/main /var/log/postgresql/postgresql-15-main.log&lt;br&gt;17 main 5432 online postgres /var/lib/postgresql/17/main /var/log/postgresql/postgresql-17-main.log&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Note the swapped ports.&lt;/p&gt;
&lt;p&gt;At this stage we can bring our mastodon services back online:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;systemctl start mastodon-sidekiq&lt;br&gt;systemctl start mastodon-streaming&lt;br&gt;systemctl start mastodon-web&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;You can now navigate to the Mastodon admin dashboard on your instance at &lt;code&gt;/admin/dashboard&lt;/code&gt; and at the bottom it should show you are using PostgreSQL 17 now.&lt;/p&gt;
&lt;h2 id="tidying-up"&gt;Tidying up&lt;/h2&gt;
&lt;p&gt;Once you have verified that this is all working, you can do some tidy up. First drop the PostgreSQL 15 cluster:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pg_dropcluster 15 main&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Then remove the PostgreSQL 15 packages:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;apt remove postgresql-15 postgresql-client-15&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="summary"&gt;Summary&lt;/h2&gt;
&lt;p&gt;Really, these are a lot of words to say: Upgrading your PostgreSQL 15 cluster to PostgreSQL is very simple, if you are hosting your Mastodon instance on Ubuntu. The actual upgrade boils down to just two commands:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;pg_dropcluster 17 main --stop&lt;br&gt;pg_upgradecluster -m link 15 main&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;It&amp;rsquo;s really quick too, taking just about a minute on my database.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/114635568989661307"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>OpenAI's crawler is overriding your Sentry sampling rate</title><link>https://blog.thms.uk/2025/05/openai-traceparent-sentry-sampling/</link><guid>https://blog.thms.uk/2025/05/openai-traceparent-sentry-sampling/</guid><pubDate>Tue, 06 May 2025 06:15:00 +0000</pubDate><category domain="https://blog.thms.uk/tag/openai/">openai</category><description>After days of debugging unexplained span usage in Sentry - despite an ultra-low sampling rate - I discovered `traceparent` headers in requests traced back to OpenAI. Turns out, it's always AI.</description><content:encoded>&lt;h1 id="openais-crawler-is-overriding-your-sentry-sampling-rate"&gt;OpenAI&amp;rsquo;s crawler is overriding your Sentry sampling rate&lt;/h1&gt;
&lt;p&gt;This is one of those ‘I cannot believe it!&amp;rsquo; moments:&lt;/p&gt;
&lt;p&gt;I have been debugging for days why Sentry would use up all our transaction spans. I had reduced our &lt;code&gt;traces_sampler&lt;/code&gt; rate from &lt;code&gt;0.1&lt;/code&gt; to &lt;code&gt;0.01&lt;/code&gt; to &lt;code&gt;0.00001&lt;/code&gt;, and seen no difference in the consumption of spans, so this told me that Sentry was ignoring what we gave them.&lt;/p&gt;
&lt;p&gt;Strange! Well, I finally opened a ticket, and Sentry (kudos to their support team! They gave a human, helpful answer within 2 hours!) provided a good pointer:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;I checked one of your transactions and noticed that it has the following header in the incoming request:&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;Traceparent 00-[redacted]-01
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The header tells the transaction should be sampled and takes preference over the sample rate you set in your SDK configuration to keep the trace consistent. Are you using tracing with other libraries/OTEL?&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Huh, strange. As far as I know we aren&amp;rsquo;t using any other tracing library. Where could this come from?&lt;/p&gt;
&lt;p&gt;To be sure, I checked our CloudFlare configuration, and found nothing, so I decided to log incoming request headers from nginx. Honestly, at this stage I was absolutely convinced Sentry had sent me on a wild goose chase.&lt;/p&gt;
&lt;p&gt;I watched my logs in real time for a few minutes and saw no &lt;code&gt;traceparent&lt;/code&gt; headers come in.&lt;/p&gt;
&lt;p&gt;Whenever I hit a point where I don&amp;rsquo;t know what to do, I make a coffee (or another drink), because I&amp;rsquo;ve long learned that I get my best revelations when I&amp;rsquo;m away from the screen/problem. So off I go for my coffee.&lt;/p&gt;
&lt;p&gt;This time, the coffee machine offered no enlightenment. But when I came back a few minutes later - log tail still running - I saw it: &lt;code&gt;traceparent&lt;/code&gt; headers lighting up the screen.&lt;/p&gt;
&lt;p&gt;Here’s one (IDs redacted, just in case):&lt;/p&gt;
&lt;pre tabindex="0"&gt;&lt;code&gt;GET /
Host: example.com
Connection: close
cf-ray: [redacted]
cdn-loop: cloudflare; loops=1
tracestate: dd=p:[redacted];s:2;t.dm:-4;t.tid:[redacted]
x-forwarded-for: 68.221.75.25
accept-encoding: gzip, br
x-request-id: [redacted]
x-forwarded-proto: https
x-openai-internal-caller: browse
x-openai-product-sku: unknown
cf-visitor: {&amp;#34;scheme&amp;#34;:&amp;#34;https&amp;#34;}
cf-ipcountry: ES
x-openai-originator-env: prod
cf-connecting-ip: 68.221.75.25
x-openai-originator: browse
x-openai-traffic-source: user
x-envoy-expected-rq-timeout-ms: 15000
x-datadog-origin: rum
user-agent: Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko); compatible; ChatGPT-User/1.0; +https://openai.com/bot
accept-language: en-US,en;q=0.9
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
x-datadog-trace-id: [redacted]
x-datadog-parent-id: [redacted]
x-datadog-sampling-priority: 2
x-datadog-tags: _dd.p.tid=[redacted],_dd.p.dm=-4
traceparent: 00-[redacted]-01
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;It appears as though OpenAI&amp;rsquo;s crawler sets tracing headers on every request it makes. Every tracing library it touches dutifully obeys, samples the trace, and bills the site owner for the privilege. I doubt it&amp;rsquo;s deliberate. It&amp;rsquo;s just the kind of thing that happens when you crawl the entire web with production defaults switched on.&lt;/p&gt;
&lt;h2&gt;Comments&lt;/h2&gt;&lt;p&gt;&lt;a href="https://mstdn.thms.uk/@michael/114461719716988373"&gt;Join the conversation on the Fediverse.&lt;/a&gt;&lt;/p&gt;</content:encoded></item></channel></rss>