<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="/feeds.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:base="https://chameth.com/">
    <title>Chameth.com - posts like coming-around-on-llms, debugging-beyond-the-debugger, tailscale-docker-gotcha but not migrating-from-github-to-forgejo, why-you-should-be-using-https</title>
    <subtitle>Personal homepage of Chris Smith</subtitle>
    <link href="https://chameth.com/feeds/posts/like/coming-around-on-llms,debugging-beyond-the-debugger,tailscale-docker-gotcha/unlike/migrating-from-github-to-forgejo,why-you-should-be-using-https/" rel="self"/>
    <link href="https://chameth.com/"/>
    <icon>https://chameth.com/favicon.png</icon>
    <updated>2026-09-01T00:00:00Z</updated>
    <id>https://chameth.com/</id>
    <author>
        <name>Chris Smith</name>
    </author>
    <entry>
        <title>Monthly Meanderings: August 2026</title>
        <link href="https://chameth.com/monthly-meanderings-2026-08/"/>
        <updated>2026-09-01T00:00:00Z</updated>
        <id>https://chameth.com/monthly-meanderings-2026-08/</id>
        <content xml:lang="en" type="html">&lt;p&gt;Another month, another edition of “What’s Chris been up to?”. Fun fact: I very nearly published this post with a “[intro]” placeholder here instead of this
paragraph, despite proofreading it multiple times. “Yep, that’s where the intro will go, all good”. Now instead we have this weirdly meta introduction.&lt;/p&gt;
&lt;h3 id=&#34;website-updates-and-other-projects&#34;&gt;Website updates and other projects&lt;/h3&gt;
&lt;p&gt;A few website updates this month, and one of them neatly addresses the “other projects” bit too. First, I’ve added a new &lt;a href=&#34;https://chameth.com/fitness/&#34;&gt;fitness page&lt;/a&gt; which has some stats
and information about my cycling and running. I’ve also added some fitness-related widgets to support that page and these monthly summaries, and a new music one you can
see a bit further down.&lt;/p&gt;
&lt;p&gt;I’ve also added what I think is quite a fun new feature: a &lt;a href=&#34;https://chameth.com/feeds/posts/build/&#34;&gt;post feed builder&lt;/a&gt; that lets you build RSS feeds containing
only posts that are semantically similar (or dissimilar) to other posts. So if you want to only hear about my fitness journey you can pin a few of those
posts, or if you want to exclude posts that talk about LLMs you can exclude a few of those. It’s basically super-charged fuzzy tags, but it means I don’t
have to actually tag things.&lt;/p&gt;
&lt;p&gt;I blogged about &lt;a href=&#34;https://chameth.com/building-a-family-of-single-user-services/&#34;&gt;building a family of single-user services&lt;/a&gt; which is where most of my coding
energy went this month. Those projects were almost entirely authored by LLMs, and I’m not hating the process. The models have got a lot better at coding,
and I enjoyed getting to focus on the bigger picture aspects rather than getting bogged down in details.
Rest assured, all the writing on my website is still 100% me. I am the load-bearing seam, here.&lt;/p&gt;
&lt;h3 id=&#34;entertainment&#34;&gt;Entertainment&lt;/h3&gt;
&lt;p&gt;&lt;a href=&#34;https://chameth.com/monthly-meanderings-2026-03/&#34;&gt;Back in March&lt;/a&gt; I said ‘I can’t do “what I listened to last month” stats until next month’, and then for the next four months completely forgot about it.
Well, not any more! Now I come equipped with some stats about my monthly music habits:&lt;/p&gt;
&lt;div class=&#34;played-albums raised-box&#34; data-title=&#34;Top albums · 1 Aug – 31 Aug 2026&#34;&gt;
    &lt;table class=&#34;plain-table&#34;&gt;
        &lt;thead&gt;
        &lt;tr&gt;
            &lt;th scope=&#34;col&#34;&gt;&lt;/th&gt;
            &lt;th scope=&#34;col&#34;&gt;&lt;/th&gt;
            &lt;th scope=&#34;col&#34;&gt;&lt;/th&gt;
            &lt;th scope=&#34;col&#34;&gt;Album&lt;/th&gt;
            &lt;th scope=&#34;col&#34;&gt;Artist&lt;/th&gt;
            &lt;th scope=&#34;col&#34; class=&#34;numeric&#34;&gt;Unique tracks played&lt;/th&gt;
            &lt;th scope=&#34;col&#34; class=&#34;numeric&#34;&gt;Total tracks played&lt;/th&gt;
        &lt;/tr&gt;
        &lt;/thead&gt;
        &lt;tbody&gt;
        &lt;tr&gt;
            &lt;td class=&#34;position&#34;&gt;1&lt;/td&gt;
            &lt;td class=&#34;movement movement-up&#34; title=&#34;Up from #4&#34;&gt;
                &lt;svg xmlns=&#34;http://www.w3.org/2000/svg&#34; width=&#34;16&#34; height=&#34;16&#34; viewBox=&#34;0 0 24 24&#34; fill=&#34;none&#34; stroke=&#34;currentColor&#34; stroke-width=&#34;2&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34; class=&#34;icon icon-tabler icons-tabler-outline icon-tabler-arrow-up&#34; aria-hidden=&#34;true&#34;&gt;&lt;path stroke=&#34;none&#34; d=&#34;M0 0h24v24H0z&#34; fill=&#34;none&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M12 5l0 14&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M18 11l-6 -6&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M6 11l6 -6&#34;&gt;&lt;/path&gt;&lt;/svg&gt;
            &lt;/td&gt;
            &lt;td class=&#34;art&#34;&gt;
                &lt;img src=&#34;https://chameth.com/music/albums/190/cover.jpg&#34; alt=&#34;Cover art for Fallen&#34; loading=&#34;lazy&#34;/&gt;
            &lt;/td&gt;
            &lt;td&gt;Fallen&lt;/td&gt;
            &lt;td class=&#34;artist&#34;&gt;Evanescence&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;8&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;40&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td class=&#34;position&#34;&gt;2&lt;/td&gt;
            &lt;td class=&#34;movement movement-up&#34; title=&#34;Up from #5&#34;&gt;
                &lt;svg xmlns=&#34;http://www.w3.org/2000/svg&#34; width=&#34;16&#34; height=&#34;16&#34; viewBox=&#34;0 0 24 24&#34; fill=&#34;none&#34; stroke=&#34;currentColor&#34; stroke-width=&#34;2&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34; class=&#34;icon icon-tabler icons-tabler-outline icon-tabler-arrow-up&#34; aria-hidden=&#34;true&#34;&gt;&lt;path stroke=&#34;none&#34; d=&#34;M0 0h24v24H0z&#34; fill=&#34;none&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M12 5l0 14&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M18 11l-6 -6&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M6 11l6 -6&#34;&gt;&lt;/path&gt;&lt;/svg&gt;
            &lt;/td&gt;
            &lt;td class=&#34;art&#34;&gt;
                &lt;img src=&#34;https://chameth.com/music/albums/341/cover.jpg&#34; alt=&#34;Cover art for Love, Drugs &amp;amp; Misery&#34; loading=&#34;lazy&#34;/&gt;
            &lt;/td&gt;
            &lt;td&gt;Love, Drugs &amp;amp; Misery&lt;/td&gt;
            &lt;td class=&#34;artist&#34;&gt;Eva Under Fire&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;12&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;32&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td class=&#34;position&#34;&gt;3&lt;/td&gt;
            &lt;td class=&#34;movement movement-up&#34; title=&#34;Up from #10&#34;&gt;
                &lt;svg xmlns=&#34;http://www.w3.org/2000/svg&#34; width=&#34;16&#34; height=&#34;16&#34; viewBox=&#34;0 0 24 24&#34; fill=&#34;none&#34; stroke=&#34;currentColor&#34; stroke-width=&#34;2&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34; class=&#34;icon icon-tabler icons-tabler-outline icon-tabler-arrow-up&#34; aria-hidden=&#34;true&#34;&gt;&lt;path stroke=&#34;none&#34; d=&#34;M0 0h24v24H0z&#34; fill=&#34;none&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M12 5l0 14&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M18 11l-6 -6&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M6 11l6 -6&#34;&gt;&lt;/path&gt;&lt;/svg&gt;
            &lt;/td&gt;
            &lt;td class=&#34;art&#34;&gt;
                &lt;img src=&#34;https://chameth.com/music/albums/378/cover.jpg&#34; alt=&#34;Cover art for NEON VOID&#34; loading=&#34;lazy&#34;/&gt;
            &lt;/td&gt;
            &lt;td&gt;NEON VOID&lt;/td&gt;
            &lt;td class=&#34;artist&#34;&gt;Sumo Cyco&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;6&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;23&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td class=&#34;position&#34;&gt;4&lt;/td&gt;
            &lt;td class=&#34;movement movement-down&#34; title=&#34;Down from #1&#34;&gt;
                &lt;svg xmlns=&#34;http://www.w3.org/2000/svg&#34; width=&#34;16&#34; height=&#34;16&#34; viewBox=&#34;0 0 24 24&#34; fill=&#34;none&#34; stroke=&#34;currentColor&#34; stroke-width=&#34;2&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34; class=&#34;icon icon-tabler icons-tabler-outline icon-tabler-arrow-down&#34; aria-hidden=&#34;true&#34;&gt;&lt;path stroke=&#34;none&#34; d=&#34;M0 0h24v24H0z&#34; fill=&#34;none&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M12 5l0 14&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M18 13l-6 6&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M6 13l6 6&#34;&gt;&lt;/path&gt;&lt;/svg&gt;
            &lt;/td&gt;
            &lt;td class=&#34;art&#34;&gt;
                &lt;img src=&#34;https://chameth.com/music/albums/522/cover.jpg&#34; alt=&#34;Cover art for The Strange Case of…&#34; loading=&#34;lazy&#34;/&gt;
            &lt;/td&gt;
            &lt;td&gt;The Strange Case of…&lt;/td&gt;
            &lt;td class=&#34;artist&#34;&gt;Halestorm&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;11&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;21&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td class=&#34;position&#34;&gt;5&lt;/td&gt;
            &lt;td class=&#34;movement movement-up&#34; title=&#34;Up from #7&#34;&gt;
                &lt;svg xmlns=&#34;http://www.w3.org/2000/svg&#34; width=&#34;16&#34; height=&#34;16&#34; viewBox=&#34;0 0 24 24&#34; fill=&#34;none&#34; stroke=&#34;currentColor&#34; stroke-width=&#34;2&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34; class=&#34;icon icon-tabler icons-tabler-outline icon-tabler-arrow-up&#34; aria-hidden=&#34;true&#34;&gt;&lt;path stroke=&#34;none&#34; d=&#34;M0 0h24v24H0z&#34; fill=&#34;none&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M12 5l0 14&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M18 11l-6 -6&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M6 11l6 -6&#34;&gt;&lt;/path&gt;&lt;/svg&gt;
            &lt;/td&gt;
            &lt;td class=&#34;art&#34;&gt;
                &lt;img src=&#34;https://chameth.com/music/albums/120/cover.jpg&#34; alt=&#34;Cover art for Coming in Hot&#34; loading=&#34;lazy&#34;/&gt;
            &lt;/td&gt;
            &lt;td&gt;Coming in Hot&lt;/td&gt;
            &lt;td class=&#34;artist&#34;&gt;DIAMANTE&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;9&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;20&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td class=&#34;position&#34;&gt;6&lt;/td&gt;
            &lt;td class=&#34;movement movement-up&#34; title=&#34;Up from #8&#34;&gt;
                &lt;svg xmlns=&#34;http://www.w3.org/2000/svg&#34; width=&#34;16&#34; height=&#34;16&#34; viewBox=&#34;0 0 24 24&#34; fill=&#34;none&#34; stroke=&#34;currentColor&#34; stroke-width=&#34;2&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34; class=&#34;icon icon-tabler icons-tabler-outline icon-tabler-arrow-up&#34; aria-hidden=&#34;true&#34;&gt;&lt;path stroke=&#34;none&#34; d=&#34;M0 0h24v24H0z&#34; fill=&#34;none&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M12 5l0 14&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M18 11l-6 -6&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M6 11l6 -6&#34;&gt;&lt;/path&gt;&lt;/svg&gt;
            &lt;/td&gt;
            &lt;td class=&#34;art&#34;&gt;
                &lt;img src=&#34;https://chameth.com/music/albums/180/cover.jpg&#34; alt=&#34;Cover art for Everest&#34; loading=&#34;lazy&#34;/&gt;
            &lt;/td&gt;
            &lt;td&gt;Everest&lt;/td&gt;
            &lt;td class=&#34;artist&#34;&gt;Halestorm&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;7&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;20&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td class=&#34;position&#34;&gt;7&lt;/td&gt;
            &lt;td class=&#34;movement movement-up&#34; title=&#34;Up from #126&#34;&gt;
                &lt;svg xmlns=&#34;http://www.w3.org/2000/svg&#34; width=&#34;16&#34; height=&#34;16&#34; viewBox=&#34;0 0 24 24&#34; fill=&#34;none&#34; stroke=&#34;currentColor&#34; stroke-width=&#34;2&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34; class=&#34;icon icon-tabler icons-tabler-outline icon-tabler-arrow-up&#34; aria-hidden=&#34;true&#34;&gt;&lt;path stroke=&#34;none&#34; d=&#34;M0 0h24v24H0z&#34; fill=&#34;none&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M12 5l0 14&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M18 11l-6 -6&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M6 11l6 -6&#34;&gt;&lt;/path&gt;&lt;/svg&gt;
            &lt;/td&gt;
            &lt;td class=&#34;art&#34;&gt;
                &lt;img src=&#34;https://chameth.com/music/albums/227/cover.jpg&#34; alt=&#34;Cover art for Glory Days&#34; loading=&#34;lazy&#34;/&gt;
            &lt;/td&gt;
            &lt;td&gt;Glory Days&lt;/td&gt;
            &lt;td class=&#34;artist&#34;&gt;Little Mix&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;5&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;20&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td class=&#34;position&#34;&gt;8&lt;/td&gt;
            &lt;td class=&#34;movement movement-down&#34; title=&#34;Down from #3&#34;&gt;
                &lt;svg xmlns=&#34;http://www.w3.org/2000/svg&#34; width=&#34;16&#34; height=&#34;16&#34; viewBox=&#34;0 0 24 24&#34; fill=&#34;none&#34; stroke=&#34;currentColor&#34; stroke-width=&#34;2&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34; class=&#34;icon icon-tabler icons-tabler-outline icon-tabler-arrow-down&#34; aria-hidden=&#34;true&#34;&gt;&lt;path stroke=&#34;none&#34; d=&#34;M0 0h24v24H0z&#34; fill=&#34;none&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M12 5l0 14&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M18 13l-6 6&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M6 13l6 6&#34;&gt;&lt;/path&gt;&lt;/svg&gt;
            &lt;/td&gt;
            &lt;td class=&#34;art&#34;&gt;
                &lt;img src=&#34;https://chameth.com/music/albums/212/cover.jpg&#34; alt=&#34;Cover art for From Zero&#34; loading=&#34;lazy&#34;/&gt;
            &lt;/td&gt;
            &lt;td&gt;From Zero&lt;/td&gt;
            &lt;td class=&#34;artist&#34;&gt;Linkin Park&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;9&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;19&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td class=&#34;position&#34;&gt;9&lt;/td&gt;
            &lt;td class=&#34;movement movement-up&#34; title=&#34;Up from #78&#34;&gt;
                &lt;svg xmlns=&#34;http://www.w3.org/2000/svg&#34; width=&#34;16&#34; height=&#34;16&#34; viewBox=&#34;0 0 24 24&#34; fill=&#34;none&#34; stroke=&#34;currentColor&#34; stroke-width=&#34;2&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34; class=&#34;icon icon-tabler icons-tabler-outline icon-tabler-arrow-up&#34; aria-hidden=&#34;true&#34;&gt;&lt;path stroke=&#34;none&#34; d=&#34;M0 0h24v24H0z&#34; fill=&#34;none&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M12 5l0 14&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M18 11l-6 -6&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M6 11l6 -6&#34;&gt;&lt;/path&gt;&lt;/svg&gt;
            &lt;/td&gt;
            &lt;td class=&#34;art&#34;&gt;
                &lt;img src=&#34;https://chameth.com/music/albums/310/cover.jpg&#34; alt=&#34;Cover art for KPop Demon Hunters (Soundtrack from the Netflix Film)&#34; loading=&#34;lazy&#34;/&gt;
            &lt;/td&gt;
            &lt;td&gt;KPop Demon Hunters (Soundtrack from the Netflix Film)&lt;/td&gt;
            &lt;td class=&#34;artist&#34;&gt;KPop Demon Hunters Cast&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;7&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;19&lt;/td&gt;
        &lt;/tr&gt;
        &lt;tr&gt;
            &lt;td class=&#34;position&#34;&gt;10&lt;/td&gt;
            &lt;td class=&#34;movement movement-up&#34; title=&#34;Up from #430&#34;&gt;
                &lt;svg xmlns=&#34;http://www.w3.org/2000/svg&#34; width=&#34;16&#34; height=&#34;16&#34; viewBox=&#34;0 0 24 24&#34; fill=&#34;none&#34; stroke=&#34;currentColor&#34; stroke-width=&#34;2&#34; stroke-linecap=&#34;round&#34; stroke-linejoin=&#34;round&#34; class=&#34;icon icon-tabler icons-tabler-outline icon-tabler-arrow-up&#34; aria-hidden=&#34;true&#34;&gt;&lt;path stroke=&#34;none&#34; d=&#34;M0 0h24v24H0z&#34; fill=&#34;none&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M12 5l0 14&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M18 11l-6 -6&#34;&gt;&lt;/path&gt;&lt;path d=&#34;M6 11l6 -6&#34;&gt;&lt;/path&gt;&lt;/svg&gt;
            &lt;/td&gt;
            &lt;td class=&#34;art&#34;&gt;
                &lt;img src=&#34;https://chameth.com/music/albums/387/cover.jpg&#34; alt=&#34;Cover art for No Need to Argue&#34; loading=&#34;lazy&#34;/&gt;
            &lt;/td&gt;
            &lt;td&gt;No Need to Argue&lt;/td&gt;
            &lt;td class=&#34;artist&#34;&gt;The Cranberries&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;6&lt;/td&gt;
            &lt;td class=&#34;numeric&#34;&gt;19&lt;/td&gt;
        &lt;/tr&gt;
        &lt;/tbody&gt;
    &lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;I suspect I won’t show this every month, as it’s probably going to be quite same-y. I, shockingly, tend to play music that I like more than that which I don’t.
I’ll usually either put on my “favourites” playlist, or my “daily mix” playlist which biases higher-rated tracks over lower ones. So effectively this just
becomes a noisy view over how highly I’ve rated the tracks in an album. Maybe I can control for that, but then I’m not sure what it’d actually be showing…
“Here are all the albums I played this month that I don’t usually play because I don’t like them as much as the other albums I’m not showing any more”?
It’s a bit wordy. Back to the drawing board with that idea, I think.&lt;/p&gt;
&lt;p&gt;I was away for a few weeks this month, including a few days at a small LAN event. Being at a computer gaming event meant that we, naturally, played some board games.
Apparently I hit an h-index of 23 from these plays. That’s 23 games I’ve played at least 23 times each. I’ve been at 22 since December last year: obviously
the higher the number goes, the harder it is to reach. I also go on and off games (and the whole hobby at times), which doesn’t help on that front. Anyway, here’s
&lt;del&gt;Wonderwall&lt;/del&gt; the plays for this month:&lt;/p&gt;
&lt;ul class=&#34;played-boardgames&#34;&gt;
  &lt;li class=&#34;raised-box&#34;&gt;
    &lt;div class=&#34;image-container&#34; title=&#34;Codenames (2015)&#34;&gt;
      &lt;img class=&#34;background&#34; src=&#34;https://chameth.com/boardgames/178900/image.jpg&#34; aria-hidden=&#34;true&#34; loading=&#34;lazy&#34;/&gt;
      &lt;img class=&#34;foreground&#34; src=&#34;https://chameth.com/boardgames/178900/image.jpg&#34; alt=&#34;Box art of Codenames&#34; loading=&#34;lazy&#34;/&gt;
    &lt;/div&gt;
    &lt;p&gt;6 plays&lt;/p&gt;
  &lt;/li&gt;
  &lt;li class=&#34;raised-box&#34;&gt;
    &lt;div class=&#34;image-container&#34; title=&#34;Just One (2018)&#34;&gt;
      &lt;img class=&#34;background&#34; src=&#34;https://chameth.com/boardgames/254640/image.jpg&#34; aria-hidden=&#34;true&#34; loading=&#34;lazy&#34;/&gt;
      &lt;img class=&#34;foreground&#34; src=&#34;https://chameth.com/boardgames/254640/image.jpg&#34; alt=&#34;Box art of Just One&#34; loading=&#34;lazy&#34;/&gt;
    &lt;/div&gt;
    &lt;p&gt;3 plays&lt;/p&gt;
  &lt;/li&gt;
  &lt;li class=&#34;raised-box&#34;&gt;
    &lt;div class=&#34;image-container&#34; title=&#34;Martian Fluxx (2009)&#34;&gt;
      &lt;img class=&#34;background&#34; src=&#34;https://chameth.com/boardgames/35369/image.jpg&#34; aria-hidden=&#34;true&#34; loading=&#34;lazy&#34;/&gt;
      &lt;img class=&#34;foreground&#34; src=&#34;https://chameth.com/boardgames/35369/image.jpg&#34; alt=&#34;Box art of Martian Fluxx&#34; loading=&#34;lazy&#34;/&gt;
    &lt;/div&gt;
    &lt;p&gt;3 plays&lt;/p&gt;
  &lt;/li&gt;
  &lt;li class=&#34;raised-box&#34;&gt;
    &lt;div class=&#34;image-container&#34; title=&#34;Arkham Horror: Lovecraft Letter (2025)&#34;&gt;
      &lt;img class=&#34;background&#34; src=&#34;https://chameth.com/boardgames/424784/image.png&#34; aria-hidden=&#34;true&#34; loading=&#34;lazy&#34;/&gt;
      &lt;img class=&#34;foreground&#34; src=&#34;https://chameth.com/boardgames/424784/image.png&#34; alt=&#34;Box art of Arkham Horror: Lovecraft Letter&#34; loading=&#34;lazy&#34;/&gt;
    &lt;/div&gt;
    &lt;p&gt;1 play&lt;/p&gt;
  &lt;/li&gt;
  &lt;li class=&#34;raised-box&#34;&gt;
    &lt;div class=&#34;image-container&#34; title=&#34;Take 5 (1994)&#34;&gt;
      &lt;img class=&#34;background&#34; src=&#34;https://chameth.com/boardgames/432/image.jpg&#34; aria-hidden=&#34;true&#34; loading=&#34;lazy&#34;/&gt;
      &lt;img class=&#34;foreground&#34; src=&#34;https://chameth.com/boardgames/432/image.jpg&#34; alt=&#34;Box art of Take 5&#34; loading=&#34;lazy&#34;/&gt;
    &lt;/div&gt;
    &lt;p&gt;1 play&lt;/p&gt;
  &lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;No films this month, but my &lt;a href=&#34;https://chameth.com/wow/&#34;&gt;World of Warcraft&lt;/a&gt; habit has picked back up with the launch of the new season. They’ve made Mistweaver Monk, my previous specialisation, a
lot less fun to play, so I’m focusing on one of my many alts: a Restoration Shaman:&lt;/p&gt;
&lt;div class=&#34;wow-char-grid&#34;&gt;
&lt;div class=&#34;wow-char raised-box&#34; data-title=&#34;World of Warcraft Character Data&#34;&gt;
&lt;div class=&#34;overview&#34;&gt;
&lt;img src=&#34;https://chameth.com/wow/characters/Hirameth.png&#34; alt=&#34;Hirameth&#34; loading=&#34;lazy&#34;/&gt;
&lt;p class=&#34;detail&#34;&gt;Level 90&lt;/p&gt;
&lt;p class=&#34;detail&#34;&gt;Female Dwarf&lt;/p&gt;
&lt;p class=&#34;detail&#34;&gt;&lt;span class=&#34;wow-class-shaman&#34;&gt;Restoration Shaman&lt;/span&gt;&lt;/p&gt;
&lt;p class=&#34;detail&#34;&gt;309 average item level&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;info&#34;&gt;
&lt;h3 class=&#34;plain-header&#34;&gt;Hirameth&lt;span class=&#34;realm&#34;&gt;-Terenas&lt;/span&gt;&lt;/h3&gt;
&lt;h4 class=&#34;plain-header&#34;&gt;Professions&lt;/h4&gt;
&lt;table class=&#34;plain-table professions&#34;&gt;
&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Midnight Blacksmithing&lt;/td&gt;&lt;td&gt;&lt;progress class=&#34;tier-progress&#34; value=&#34;41&#34; max=&#34;100&#34;&gt;&lt;/progress&gt;&lt;/td&gt;&lt;td class=&#34;tier-count&#34;&gt;41/100&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Midnight Inscription&lt;/td&gt;&lt;td&gt;&lt;progress class=&#34;tier-progress&#34; value=&#34;72&#34; max=&#34;100&#34;&gt;&lt;/progress&gt;&lt;/td&gt;&lt;td class=&#34;tier-count&#34;&gt;72/100&lt;/td&gt;&lt;/tr&gt;
&lt;tr&gt;&lt;td&gt;Midnight Fishing&lt;/td&gt;&lt;td&gt;&lt;progress class=&#34;tier-progress&#34; value=&#34;35&#34; max=&#34;300&#34;&gt;&lt;/progress&gt;&lt;/td&gt;&lt;td class=&#34;tier-count&#34;&gt;35/300&lt;/td&gt;&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;h4 class=&#34;plain-header&#34;&gt;Mythic+&lt;/h4&gt;
&lt;table class=&#34;plain-table mythic-plus&#34;&gt;
&lt;tbody&gt;&lt;tr&gt;
&lt;td class=&#34;dungeon&#34;&gt;Altar of Fangs&lt;/td&gt;
&lt;td class=&#34;level&#34;&gt;+10&lt;/td&gt;
&lt;td class=&#34;duration&#34;&gt;23:10&lt;/td&gt;
&lt;td class=&#34;rating&#34;&gt;329&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&#34;dungeon&#34;&gt;Den of Nalorakk&lt;/td&gt;
&lt;td class=&#34;level&#34;&gt;+10&lt;/td&gt;
&lt;td class=&#34;duration&#34;&gt;27:36&lt;/td&gt;
&lt;td class=&#34;rating&#34;&gt;325&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&#34;dungeon&#34;&gt;Kings&amp;#39; Rest&lt;/td&gt;
&lt;td class=&#34;level&#34;&gt;+12&lt;/td&gt;
&lt;td class=&#34;duration&#34;&gt;28:57&lt;/td&gt;
&lt;td class=&#34;rating&#34;&gt;370&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&#34;dungeon&#34;&gt;Murder Row&lt;/td&gt;
&lt;td class=&#34;level&#34;&gt;+12&lt;/td&gt;
&lt;td class=&#34;duration&#34;&gt;28:37&lt;/td&gt;
&lt;td class=&#34;rating&#34;&gt;371&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&#34;dungeon&#34;&gt;Ruby Life Pools&lt;/td&gt;
&lt;td class=&#34;level&#34;&gt;+11&lt;/td&gt;
&lt;td class=&#34;duration&#34;&gt;24:28&lt;/td&gt;
&lt;td class=&#34;rating&#34;&gt;340&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&#34;dungeon&#34;&gt;Temple of Sethraliss&lt;/td&gt;
&lt;td class=&#34;level&#34;&gt;+10&lt;/td&gt;
&lt;td class=&#34;duration&#34;&gt;30:57&lt;/td&gt;
&lt;td class=&#34;rating&#34;&gt;321&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&#34;dungeon&#34;&gt;The Blinding Vale&lt;/td&gt;
&lt;td class=&#34;level&#34;&gt;+10&lt;/td&gt;
&lt;td class=&#34;duration&#34;&gt;24:38&lt;/td&gt;
&lt;td class=&#34;rating&#34;&gt;327&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td class=&#34;dungeon&#34;&gt;Voidscar Arena&lt;/td&gt;
&lt;td class=&#34;level&#34;&gt;+10&lt;/td&gt;
&lt;td class=&#34;duration&#34;&gt;22:37&lt;/td&gt;
&lt;td class=&#34;rating&#34;&gt;329&lt;/td&gt;
&lt;/tr&gt;
&lt;tr class=&#34;total&#34;&gt;
&lt;th colspan=&#34;3&#34; class=&#34;plain-header&#34;&gt;Total M+ rating&lt;/th&gt;
&lt;td class=&#34;rating&#34;&gt;2711&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;&lt;/table&gt;
&lt;div class=&#34;links&#34;&gt;
&lt;a href=&#34;https://worldofwarcraft.blizzard.com/en-gb/character/eu/terenas/hirameth&#34;&gt;View on blizzard.com&lt;/a&gt;
&lt;a href=&#34;https://raider.io/characters/eu/terenas/Hirameth&#34;&gt;View on raider.io&lt;/a&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;I don’t quite like it as much as Mistweaver in season 1, but it’s a lot nicer than Mistweaver in season 2. You can actually make health bars move when you press your healing buttons, rather than
ignoring half of them because they’re not worth using.&lt;/p&gt;
&lt;h3 id=&#34;fitness&#34;&gt;Fitness&lt;/h3&gt;
&lt;p&gt;My running kick is still going. I’ve “officially” finished the couch-to-5k programme I was doing, and I actually hit 5k on the last day of the programme. I didn’t actually mean to do that:
I was saving the 5k run for this week, but by the time I noticed I’d run “too far” it was close enough that I just kept going. I have an actual 5k race coming up
this month, which is exciting. Once that’s over I’m planning to work my way up to 10k. I’ve added a few widgets to keep track of some stats:&lt;/p&gt;
&lt;div class=&#34;workout-summary-sections&#34;&gt;
&lt;div class=&#34;workout-summary-section raised-box&#34; data-title=&#34;Cycling · 1 Aug – 31 Aug 2026&#34;&gt;
&lt;div class=&#34;stat-row&#34;&gt;
&lt;div class=&#34;stat&#34;&gt;
&lt;p class=&#34;stat-value&#34;&gt;3&lt;/p&gt;
&lt;p class=&#34;stat-label&#34;&gt;activities&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;stat&#34;&gt;
&lt;p class=&#34;stat-value&#34;&gt;6h 5m&lt;/p&gt;
&lt;p class=&#34;stat-label&#34;&gt;total time&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;stat&#34;&gt;
&lt;p class=&#34;stat-value&#34;&gt;103.6km&lt;/p&gt;
&lt;p class=&#34;stat-label&#34;&gt;total distance&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;stat&#34;&gt;
&lt;p class=&#34;stat-value&#34;&gt;42.3km&lt;/p&gt;
&lt;p class=&#34;stat-label&#34;&gt;longest distance&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul class=&#34;workout-summary-pbs&#34;&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;1km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;2:17&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;1:52&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;2km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;5:34&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;4:13&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;5km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;14:51&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;12:32&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;10km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;36:00&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;27:21&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;20km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;1:43:33&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;1:13:24&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;30km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;no previous&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;1:54:25&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;40km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;no previous&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;2:55:52&lt;/span&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;div class=&#34;workout-summary-section raised-box&#34; data-title=&#34;Running · 1 Aug – 31 Aug 2026&#34;&gt;
&lt;div class=&#34;stat-row&#34;&gt;
&lt;div class=&#34;stat&#34;&gt;
&lt;p class=&#34;stat-value&#34;&gt;12&lt;/p&gt;
&lt;p class=&#34;stat-label&#34;&gt;activities&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;stat&#34;&gt;
&lt;p class=&#34;stat-value&#34;&gt;6h 16m&lt;/p&gt;
&lt;p class=&#34;stat-label&#34;&gt;total time&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;stat&#34;&gt;
&lt;p class=&#34;stat-value&#34;&gt;43.1km&lt;/p&gt;
&lt;p class=&#34;stat-label&#34;&gt;total distance&lt;/p&gt;
&lt;/div&gt;
&lt;div class=&#34;stat&#34;&gt;
&lt;p class=&#34;stat-value&#34;&gt;5km&lt;/p&gt;
&lt;p class=&#34;stat-label&#34;&gt;longest distance&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul class=&#34;workout-summary-pbs&#34;&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;800m&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;5:56&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;6:02&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;1km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;7:35&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;7:36&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;1 mile&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;12:28&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;12:16&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;2km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;15:35&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;15:16&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;3km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;24:32&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;23:05&lt;/span&gt;
&lt;/li&gt;
&lt;li class=&#34;workout-summary-pb&#34;&gt;
&lt;span class=&#34;pb-trophy&#34;&gt;🏆️&lt;/span&gt;
&lt;span class=&#34;pb-label&#34;&gt;New PB&lt;/span&gt;
&lt;span class=&#34;pb-distance&#34;&gt;5km&lt;/span&gt;
&lt;span class=&#34;pb-previous&#34;&gt;no previous&lt;/span&gt;
&lt;span class=&#34;pb-time&#34;&gt;38:40&lt;/span&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;As well as the running, I did a couple of long (for me) cycle rides, clocking in at around 40 km. It’s a fun loop, but both times I did it I started flagging towards the end. The second time
I made sure to eat and drink plenty along the way, and my legs still raised objections about halfway round. My next goal is 50 km which would let me get to the next town over and back but
the route has a lot more elevation to go with its increased length, so it’s probably going to be quite a bit more taxing. I’m not sure when I’ll attempt that, as the weather is starting to
turn. I was previously bemused when people mentioned “cycling seasons” and switching to indoor trainers. Then I wanted to go out when it was raining, and suddenly I understood. Running in
the rain: surprisingly pleasant; cycling in the rain: no thank you.&lt;/p&gt;
&lt;h3 id=&#34;around-the-web&#34;&gt;Around the web&lt;/h3&gt;
&lt;h4 id=&#34;hacker-news-in-uncompromised-detailhttpsvalerockspostshacker-news&#34;&gt;&lt;a href=&#34;https://vale.rocks/posts/hacker-news&#34;&gt;Hacker News In Uncompromised Detail&lt;/a&gt;&lt;/h4&gt;
&lt;p&gt;When I first read the title my gut reaction was “I know everything I need to know about Hacker News, right?”.
The title is accurate though. There’s so much interesting stuff, presented really cleanly. It’s a good read.&lt;/p&gt;
&lt;h4 id=&#34;a-proposal-for-new-netflix-content-rating-iconshttpsericwbaileywebsitepublisheda-proposal-for-new-netflix-content-rating-icons&#34;&gt;&lt;a href=&#34;https://ericwbailey.website/published/a-proposal-for-new-netflix-content-rating-icons/&#34;&gt;A proposal for new Netflix content rating icons&lt;/a&gt;&lt;/h4&gt;
&lt;p&gt;Another dry sounding title, another fun article. It starts with a treatise on rating systems and
ends with a lovely satirical list of rating ideas.&lt;/p&gt;
&lt;h4 id=&#34;find-git-commits-by-commit-message-texthttpshamvockecombloggit-revisions-by-text&#34;&gt;&lt;a href=&#34;https://hamvocke.com/blog/git-revisions-by-text/&#34;&gt;Find Git commits by commit message text&lt;/a&gt;&lt;/h4&gt;
&lt;p&gt;I like to think I’m relatively well versed in how to use Git. I can do an interactive rebase
and everything! This tip from Ham was new to me, though, and is incredibly useful.&lt;/p&gt;
&lt;h4 id=&#34;novice-marathon-training-planhttpswwwhalhigdoncomtraining-programsmarathon-trainingnovice-1-marathon&#34;&gt;&lt;a href=&#34;https://www.halhigdon.com/training-programs/marathon-training/novice-1-marathon/&#34;&gt;Novice Marathon Training Plan&lt;/a&gt;&lt;/h4&gt;
&lt;p&gt;Hal Higdon has a whole bunch of training plans. He literally wrote the book on marathon training.
I’ll hopefully be following this plan next year, assuming I manage the 5k, 10k and half-marathon training!
This would be the perfect plan for someone who’s run a half marathon a while ago, and is tempted
to join me in my “marathon before I’m 40” goal…&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Building a family of single-user services</title>
        <link href="https://chameth.com/building-a-family-of-single-user-services/"/>
        <updated>2026-08-27T00:00:00Z</updated>
        <id>https://chameth.com/building-a-family-of-single-user-services/</id>
        <content xml:lang="en" type="html">&lt;p&gt;Last month I &lt;a href=&#34;https://chameth.com/tracking-pbs-without-the-dark-patterns/&#34;&gt;made and wrote about&lt;/a&gt; a personal service
to track my workouts and record personal bests. Since then, things have escalated. I now have eight different
services running in the same “family”:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Collegiate Room&lt;/td&gt;
&lt;td&gt;Simple weather overview for my current location&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;German Mall&lt;/td&gt;
&lt;td&gt;LLM broker for use by other services&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gloss White&lt;/td&gt;
&lt;td&gt;Weight tracking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Olive Crosses&lt;/td&gt;
&lt;td&gt;Central service hub&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pompei Band&lt;/td&gt;
&lt;td&gt;Workout and personal best tracking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed Carp&lt;/td&gt;
&lt;td&gt;Spaced repetition system&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tiger Island&lt;/td&gt;
&lt;td&gt;Reading history and library tracking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical Horse&lt;/td&gt;
&lt;td&gt;Location history and analytics&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The names are all anagrams of something related to their function. Finding those anagrams brings me an unreasonable
amount of joy. Unscrambling them is an exercise left to the reader. On top of those eight, I currently have four other
service ideas sat in my todo list. It’s going to be a big family.&lt;/p&gt;
&lt;h3 id=&#34;a-postmortem-on-the-previous-hodge-podge&#34;&gt;A postmortem on the previous hodge-podge&lt;/h3&gt;
&lt;p&gt;Most of this functionality isn’t new to me. When I started out playing around with LLMs, I created a “personal data
aggregator” that just kept getting things bolted on. I &lt;a href=&#34;https://chameth.com/an-app-can-be-a-ready-meal/#what-ive-made&#34;&gt;said at the time&lt;/a&gt;:&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;My biggest just-for-me project is a web app that I started to help me aggregate film recommendations. It’s since morphed into a general personal data aggregation service: it deals with data from GitHub, Todoist, Letterboxd, TMDB, Healthkit, and others. It also lets me make re-orderable lists, store recipes, and more. Parts of this could definitely be open sourced, and I might carve them out at some point, but it’s mostly a glorious hodge-podge of things specific to me. Having all these services in one place lets me make quick and dirty automations, for example: when I create a Todoist note on my phone or watch, I often forget to set the due date, so it doesn’t show up in the “Today” view. It was literally a few lines of code to plumb things together so any inbox task without a due date gets set to today automatically.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;In a twist that &lt;em&gt;nobody&lt;/em&gt; could see coming, it turns out that a “glorious hodge-podge” is actually quite hard to maintain
and very annoying to work with. It didn’t help that it had a fairly extensive Svelte frontend and I really am not a fan
of JavaScript. I ended up using it less and less just because it wasn’t fun to use or work on, and I didn’t want to
waste the limited LLM tokens on doing massive refactors or reworks.&lt;/p&gt;
&lt;p&gt;I’ve got almost two decades of professional software development experience under my belt, so you’d think I would
have learnt the lessons on how to write maintainable software. Hell, my CV even mentions that as something I’m
particularly good at. So what went wrong?&lt;/p&gt;
&lt;p&gt;There were a few factors. One of the obvious things to blame is that it was written mostly with LLMs.
Most of that blame isn’t very honest, though. In the year or so since, LLMs have definitely got better at writing
good code; but I was manually reviewing all the code at the time, and telling it quite carefully what to implement
and how to do it. So the buck really stops with me.&lt;/p&gt;
&lt;p&gt;I think it’s actually more because it was my &lt;em&gt;first&lt;/em&gt; larger project using LLMs. It’s a bit like learning a new
programming language. I don’t think the first sizable project I’ve written in any language has ever been any
good. You can read and learn as much as you want, but you need to actually run into some problems headfirst to
get a grip on how the language works. Or at least I do. LLM coding agents are basically the same deal.&lt;/p&gt;
&lt;p&gt;What the LLMs actually did was mask several problems. The architecture wasn’t right for how the service developed.
It started out as a single-purpose app and gradually got things bolted on, without any major changes. Everything
ended up in horizontal slices: a massive database layer with every query used by every part of the app, a massive
HTTP layer, and so on. Working on a single feature became a tap dance all over the source tree. This sort of
friction is normally quite apparent when you’re writing code by hand, and at some point it hurts enough that
you throw in the towel and do some refactoring. But the LLM will happily plod on without exposing that pain,
so the project needed a more active kind of architecture management that it just didn’t get.&lt;/p&gt;
&lt;p&gt;The other problem they covered up was how brittle and unmaintainable the JavaScript frontend was. I’m not
even sure how I ended up with a Svelte frontend in hindsight. It’s my preferred framework, but I still only
reach for it as a last resort when there’s no other way to avoid using one. It seems like there were lots of
ways to avoid it here, but I seem to have… well… avoided them. Anyway, each feature got its own slew of
Svelte components, without much thought to reusability, shared patterns, or anything sensible. Some of them
were just thin wrappers around data supplied by Go, others had vast swathes of logic in them, with no real
rhyme or reason as to which way that would fall. It’s the same issue as before: a human would look at it,
say “WTF?” and suggest tidying it up; instead, the LLM just gets on with what you tell it to do.&lt;/p&gt;
&lt;p&gt;There’s also a big non-LLM factor: one of the primary reasons all of this unrelated functionality got
tossed into the same app was that I’d previously worked on a few projects with a microservice architecture
and really didn’t like it. My conclusion was they only really worked in large environments where you can
dedicate people or teams to working on all the boilerplate that happens from maintaining lots of
interconnected projects. Dumping everything and the kitchen sink into one badly architected monolith was,
perhaps, not the best rebound from that.&lt;/p&gt;
&lt;h3 id=&#34;pompei-band-and-beyond&#34;&gt;Pompei Band and beyond&lt;/h3&gt;
&lt;p&gt;When I wanted to implement &lt;del&gt;Strava at home&lt;/del&gt; my PB tracking app, it seemed fairly natural to build it out
as a new service, rather than bolt it on to the slowly decomposing hodge-podge that was giving off a funny
smell. Then when I wanted a weather dashboard, why not do it in the same style as Pompei Band? The same
serving architecture, the same page layout… I could even come up with a similar weird two-word name!&lt;/p&gt;
&lt;p&gt;Then wouldn’t it be nice if the weather dashboard knew where I was, so it could show the local weather?
I could just build that functionality in, but it’d also be a good opportunity to carve out the location
history feature from my old service and make a new, shinier one. Then basically every time I even thought
about going near the old service, I mentally sketched out a replacement service.&lt;/p&gt;
&lt;figure class=&#34;image right&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/building-a-family-of-single-user-services/oc.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/building-a-family-of-single-user-services/oc.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/building-a-family-of-single-user-services/oc.png&#34; alt=&#34;A screenshot of Olive Crosses, showing links to other services with icons based on their two-word names&#34; loading=&#34;lazy&#34; width=&#34;1230&#34; height=&#34;619&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;Olive Crosses showing links to the other services&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;At one point I had the thought that it’d be nice if the services could all link to one another, but I
didn’t want to have to touch every service whenever I added a new one. So I made a “hub” service, which
maintains the One True Catalogue, and provides a bit of JavaScript the others can all embed to add a
navigation sidebar.&lt;/p&gt;
&lt;p&gt;So after complaining about microservices and JavaScript, it sounds like I have landed on microservices
(or at least miniservices) and JavaScript? Sort of yes, but sort of no. Each service is still its own
thing and makes sense independent of the others. There are some cross-service dependencies, but they’re
very simple and don’t have the same cognitive or boilerplate overhead as a login service that talks to
an authentication service that queries a user manager and so on. And as for the JavaScript, it’s almost
exclusively used for progressive enhancement and a few limited client-side visual things like
maps and graphs. All of the logic lives in Go, which is a lot easier to maintain and test, and all of
the pages are simple Go templates.&lt;/p&gt;
&lt;p&gt;Having these services built in a way that I actually like has motivated me to play around a lot more
with new features:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;My workout tracking app pulls photos from my private &lt;a href=&#34;https://immich.app&#34;&gt;Immich&lt;/a&gt; server that were
taken during workouts, and shows them alongside. Now I can see all my obligatory end-of-run selfies
in one place.&lt;/li&gt;
&lt;li&gt;The location history app can detect trips away from home, and get an LLM to automatically title them.
My most recent, very exotic, trip has the title “A fortnight in Milton Keynes and Coventry”. Each
trip gets its own little map, and in the future I want to pull in images like with workouts.&lt;/li&gt;
&lt;li&gt;My weight tracking app now exports weigh-ins to Garmin, as it doesn’t fully integrate with Apple
Health (it will export its own weights, but not import others, like the ones from my Withings scales).
Stupid walled gardens, but thankfully I can fix it myself.&lt;/li&gt;
&lt;li&gt;My book tracking app automatically cleans up the titles of books from the nonsense provided by Amazon
to something more palatable. “Killing Eve: Long Shot: A twist on obsession, betrayal and revenge from
Luke Jennings, author of the TV sensation Killing Eve” becomes “Long Shot (Killing Eve #5)”. It does
this by passing the title to an LLM with a bunch of rules.&lt;/li&gt;
&lt;li&gt;The hub generates me a snippet of Tailscale configuration that defines the minimal grants needed to
allow the intra-service dependencies to work.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The LLM integrations were inspired by my friend &lt;a href=&#34;https://dataforce.org.uk/&#34;&gt;Shane&lt;/a&gt; who recently
demonstrated an inventory tracking app he’d made, which used Haiku to process images of boxes and
describe the contents. I’ve dabbled with LLM integration before, and always disliked the process
of fiddling with the prompts, and debugging when the LLM gave a nonsensical response. So I built
German Mall, which makes it a lot easier. It lets me define a “workflow” consisting of a system
prompt, initial message, and expected response format. I can browse past runs, tweak the prompt,
rerun them to see the change, all without touching the services using them. Quick iteration loops
are the best. German Mall also provides markdown documentation for each workflow, so integrating
it downstream is simply a case of pointing a coding agent at the right documentation endpoint.&lt;/p&gt;
&lt;figure class=&#34;image full&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/building-a-family-of-single-user-services/gm.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/building-a-family-of-single-user-services/gm.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/building-a-family-of-single-user-services/gm.png&#34; alt=&#34;A screenshot of German Mall, showing the workflow configuration for tidying book titles: a form with fields for description, system prompt, user template, output schema, model, max tokens and temperature.&#34; loading=&#34;lazy&#34; width=&#34;1482&#34; height=&#34;1080&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;The “tidy book titles” workflow in German Mall&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;It’s not all fun and games: having a bunch of services all following the same pattern means
you need to do cross-cutting maintenance on them now and then. But that’s surprisingly easy with LLMs.
I put all the projects in a common folder, and I launch cross-cutting tasks from that folder. Usually
I’ll make the change in one project, and then the cross-cutting task is simply “Look at commit XYZ in
./foo, make the same change to the other projects”.&lt;/p&gt;
&lt;p&gt;I’ve also ended up with a shared library used by all the projects to cut down on some boilerplate.
That’s normally something I’d resist, especially with personal projects, just because the extra overhead
of working in a separate project, managing releases of it, and so on, is a bit annoying. But, again,
LLMs can do almost all of that grunt work. They effectively reduce the “cost” of maintaining separate
services to a negligible level, leaving me to focus on the more fun aspects like coming up with new names.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>An interesting Tailscale + Docker gotcha</title>
        <link href="https://chameth.com/tailscale-docker-gotcha/"/>
        <updated>2026-01-29T00:00:00Z</updated>
        <id>https://chameth.com/tailscale-docker-gotcha/</id>
        <content xml:lang="en" type="html">&lt;p&gt;As I’ve &lt;a href=&#34;https://chameth.com/how-i-use-tailscale/&#34;&gt;written&lt;/a&gt; &lt;a href=&#34;https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/&#34;&gt;about&lt;/a&gt; &lt;a href=&#34;https://chameth.com/exposing-game-servers-over-tailscale/&#34;&gt;before&lt;/a&gt;, I use &lt;a href=&#34;https://tailscale.com/&#34;&gt;Tailscale&lt;/a&gt; for a lot of things. I thought I had it set up in a reasonably secure manner, but I recently noticed a problem.&lt;/p&gt;
&lt;p&gt;I use Tailscale’s ACLs to limit what each node can access, based on the tags I apply to it. So an &lt;code&gt;app&lt;/code&gt; node can’t access anything via Tailscale, while an &lt;code&gt;integration&lt;/code&gt; or &lt;code&gt;server&lt;/code&gt; node can access things tagged with either &lt;code&gt;app&lt;/code&gt; or &lt;code&gt;integration&lt;/code&gt;. This is expressed pretty simply in the Tailscale ACL JSON:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;tagOwners&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-c1&#34;&gt;// Servers that can be SSH&amp;#39;d into
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;tag:server&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-c1&#34;&gt;// Applications that are exposed on tailscale but never connect out
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;tag:app&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-c1&#34;&gt;// Things which talk to other services over tailscale (connecting to apps etc)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;tag:integration&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;grants&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-c1&#34;&gt;// Users can access everything
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;autogroup:member&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;ip&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;  &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-c1&#34;&gt;// Servers and integrations can access integrations and apps
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:server&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:integration&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:integration&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:app&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;ip&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;  &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;*&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Over the past week I’ve been setting up a private &lt;a href=&#34;https://forgejo.org/&#34;&gt;Forgejo&lt;/a&gt; instance behind Tailscale, complete with an actions runner that runs things using a docker-in-docker container. I didn’t want the runner knowing anything about Tailscale, so I had it configured to speak to Forgejo direct over HTTP (&lt;code&gt;forgejo:3000&lt;/code&gt;) instead of using full Tailscale HTTPS URL that I use when accessing it (&lt;code&gt;http://git.example-net.ts.net/&lt;/code&gt;)&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:1&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;Everything was going fine, until I forgot to do that translation… and it worked. My Forgejo action runners could access anything on my tailnet. I run several things on Tailscale that just have authentication turned off, on the basis that only authorised devices can access them. Things like the admin interface for this website. I definitely didn’t intend for any workflow I run on my git server to have access to edit my website!&lt;/p&gt;
&lt;p&gt;This wasn’t just limited to Forgejo, either. Any docker container I was running could access the tailnet. In hindsight it’s fairly obvious why: the host is running Tailscale, connected as a node tagged with my &lt;code&gt;server&lt;/code&gt; tag. That creates a &lt;code&gt;tailscale0&lt;/code&gt; interface, and automagically sets up iptables rules to route Tailscale traffic over the interface. Docker also automagically sets up iptables rules to bridge traffic, and apparently these two sets of rules interact in such a way that traffic from Docker containers is allowed to route via the &lt;code&gt;tailscale0&lt;/code&gt; interface.&lt;/p&gt;
&lt;p&gt;I say it’s fairly obvious in hindsight — there’s no reason why Docker would special case any particular host interface after all — but it still feels pretty surprising. Because both bits of software inject their own iptables rules, I never really had a good mental model for how they interact. The host Tailscale node was a completely separate building block to Docker. It would be a pain to use either of them if they didn’t do these rules, but it’s also one of the reasons I don’t really like “magical” things&lt;sup id=&#34;fnref:2&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:2&#34; role=&#34;doc-noteref&#34;&gt;2&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;There are lots of ways to fix this, but none of them feel particularly great. You can configure both Tailscale and Docker to not automatically fiddle with iptables and handle the rules yourself, but I really hate dealing with iptables&lt;sup id=&#34;fnref:3&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:3&#34; role=&#34;doc-noteref&#34;&gt;3&lt;/a&gt;&lt;/sup&gt;. As a stopgap I did hold my nose and add some iptables rules to drop traffic to the &lt;code&gt;tailscale0&lt;/code&gt; interface if it originated from the IP ranges that Docker was configured to use:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;iptables -I DOCKER-USER -s 192.168.0.0/16 -o tailscale0 -j DROP
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;iptables -I DOCKER-USER -s 172.17.0.0/12 -o tailscale0 -j DROP
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The &lt;code&gt;DOCKER-USER&lt;/code&gt; chain is a nice little escape hatch; it comes before the main auto-generated &lt;code&gt;DOCKER&lt;/code&gt; chain, and Docker leaves the rules in it alone.&lt;/p&gt;
&lt;p&gt;So I added these rules, and felt pretty good about myself, and then… everything started breaking in weird ways. After some debugging I realised the problem was DNS&lt;sup id=&#34;fnref:4&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:4&#34; role=&#34;doc-noteref&#34;&gt;4&lt;/a&gt;&lt;/sup&gt;. The server’s DNS resolver is &lt;code&gt;100.100.100.100&lt;/code&gt;, a special Tailscale address. This is so that it can resolve tailnet hostnames via &lt;a href=&#34;https://tailscale.com/kb/1081/magicdns&#34;&gt;MagicDNS&lt;/a&gt; (and so I don’t have to configure my custom DNS servers manually on each device; Tailscale does it for me). My new iptables rules inadvertently dropped all the DNS packets coming from docker containers. D’oh.&lt;/p&gt;
&lt;p&gt;Obviously the solution here is to double down and add MORE iptables rules:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;iptables -I DOCKER-USER -s 192.168.0.0/16 -d 100.100.100.100 -o tailscale0 -j ACCEPT
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;iptables -I DOCKER-USER -s 172.17.0.0/12 -d 100.100.100.100 -o tailscale0 -j ACCEPT
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;This got DNS working again, but it all feels a bit gross. Not least because to actually make these persist you need to use &lt;code&gt;iptables-save&lt;/code&gt;, and then you also get all the rules that both Docker and Tailscale have inserted. I came across &lt;a href=&#34;https://github.com/giangi/iptables-docker-filter&#34;&gt;a script&lt;/a&gt; to filter out the Docker ones, but… yuck&lt;sup id=&#34;fnref:5&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:5&#34; role=&#34;doc-noteref&#34;&gt;5&lt;/a&gt;&lt;/sup&gt;.&lt;/p&gt;
&lt;p&gt;I realised a cleaner idea might just be to lock down what &lt;code&gt;server&lt;/code&gt; tagged nodes could access within Tailscale itself. That way I can avoid touching iptables at all. It’s handy to be able to &lt;code&gt;curl&lt;/code&gt; services from the host when debugging, but it’s not really necessary. So I removed that access, and… stuff broke again. I’m using my Forgejo instance as a registry for some of the docker images I run, so the docker daemon needs to be able to reach it. I ended up making a new tag for &lt;code&gt;infrastructure&lt;/code&gt;, which can be accessed from &lt;code&gt;server&lt;/code&gt; devices. This does still allow all the docker containers to reach Forgejo, but I already have it set up with appropriate access controls and public/private repository splits. Forgejo is a service designed to run publicly, so this seems a reasonable trade-off for convenience. I used the &lt;code&gt;tests&lt;/code&gt; feature of Tailscale’s ACL config to make sure I’d got the rules right:&lt;/p&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;tests&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-c1&#34;&gt;// Servers can only access infrastructure
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;   &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:server&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;proto&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tcp&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;allow&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:infrastructure:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;deny&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;100.84.16.43:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;me@example.com:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:server:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:app:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;				&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;tag:integration:8080&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;	&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;&lt;span class=&#34;chroma-p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;The whole situation still feels a bit messy. If I ever get around to switching to nftables I might loop back and manually craft some rules for routing traffic, instead of leaving Tailscale and Docker to do their own thing.&lt;/p&gt;
&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;
&lt;hr/&gt;
&lt;ol&gt;
&lt;li id=&#34;fn:1&#34;&gt;
&lt;p&gt;I discovered later on that logging into the container registry at &lt;code&gt;forgejo:3000&lt;/code&gt; actually issued a redirect to &lt;code&gt;https://git.example-net.ts.net/&lt;/code&gt; so this was all basically for naught… &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:1&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:2&#34;&gt;
&lt;p&gt;I didn’t use Tailscale’s MagicDNS for a long time just because the word “magic” put me off. Only when I eventually got around to learning how it worked, and seeing that it wasn’t really that magical under the hood, did I change my mind. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:2&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:3&#34;&gt;
&lt;p&gt;nftables seems far better in lots of ways, but I can’t really be bothered migrating. Maybe next time I reimage the server for whatever reason… &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:3&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:4&#34;&gt;
&lt;p&gt;It’s always DNS… &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:4&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;li id=&#34;fn:5&#34;&gt;
&lt;p&gt;Again, nftables would almost certainly help here. It actually has (gasp) configuration files. But again, I really didn’t want to spend the time migrating. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:5&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</content>
    </entry>
    <entry>
        <title>Exposing game servers over Tailscale</title>
        <link href="https://chameth.com/exposing-game-servers-over-tailscale/"/>
        <updated>2025-12-07T00:00:00Z</updated>
        <id>https://chameth.com/exposing-game-servers-over-tailscale/</id>
        <content xml:lang="en" type="html">&lt;p&gt;I’ve recently been playing a lot of &lt;a href=&#34;https://factorio.com/&#34;&gt;Factorio&lt;/a&gt; with a friend. I’ve been
hosting, but my desktop computer is behind far too many layers of NAT, and I can’t be bothered
dealing with setting up port forwards. Up until today we made do with Steam’s networking support,
which in our case ended up relaying the connection via one of their servers. This is amazing as
a free, no-hassle service, but the performance was so-so. We’d get random lag spikes, or the
initial map download would crawl along at about 1/100th of the speed we should be able to get.
We’re both fans of &lt;a href=&#34;https://tailscale.com/&#34;&gt;Tailscale&lt;/a&gt; though, so maybe there’s an easy solution
there?&lt;/p&gt;
&lt;p&gt;Tailscale even have an article on &lt;a href=&#34;https://tailscale.com/blog/factorio-multiplayer-video&#34;&gt;sharing a Factorio server&lt;/a&gt;
but it’s a ten minute long video&lt;sup id=&#34;fnref:1&#34;&gt;&lt;a class=&#34;footnote-ref&#34; href=&#34;#fn:1&#34; role=&#34;doc-noteref&#34;&gt;1&lt;/a&gt;&lt;/sup&gt;, and focused on setting up an actual dedicated server in Docker
rather than just clicking the convenient “Host” button in the game client. What we did instead was:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;I shared my desktop’s Tailscale node with my friend, by creating a sharing link in the &lt;a href=&#34;https://login.tailscale.com/admin/machines&#34;&gt;Admin console&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;My friend accepted the link, and could then see my machine in their admin panel&lt;/li&gt;
&lt;li&gt;I used &lt;code&gt;tailscale lock&lt;/code&gt; to sign my friend’s desktop’s node key, as I have &lt;a href=&#34;https://tailscale.com/kb/1226/tailnet-lock&#34;&gt;tailnet lock&lt;/a&gt; enabled&lt;/li&gt;
&lt;li&gt;I added a grant to my tailnet’s ACL to permit access from my friend to the specific IP/port on my tailnet:&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&#34;chroma-chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;src&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;friend@example.com&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;dst&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt; &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;100.0.0.42&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;fd7a::42&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;			&lt;span class=&#34;chroma-nt&#34;&gt;&amp;#34;ip&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;:&lt;/span&gt;  &lt;span class=&#34;chroma-p&#34;&gt;[&lt;/span&gt;&lt;span class=&#34;chroma-s2&#34;&gt;&amp;#34;udp:34197&amp;#34;&lt;/span&gt;&lt;span class=&#34;chroma-p&#34;&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;chroma-line&#34;&gt;&lt;span class=&#34;chroma-cl&#34;&gt;		&lt;span class=&#34;chroma-p&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;After this, my friend could connect to Factorio by entering the Tailscale IP address, or the Tailscale hostname (as they use &lt;a href=&#34;https://tailscale.com/kb/1081/magicdns&#34;&gt;MagicDNS&lt;/a&gt;).
Running &lt;code&gt;tailscale status&lt;/code&gt; shows that Tailscale managed to establish a direct connection despite the many layers of NAT involved,
and the performance improvements were significant and immediate.&lt;/p&gt;
&lt;p&gt;We did a bit of testing, and confirmed that no other services on my machine are accessible. And sharing a machine by default
only allows it to accept incoming connections, so I can’t get unwanted access to anything on my friend’s Tailnet, either.&lt;/p&gt;
&lt;p&gt;I’ve &lt;a href=&#34;https://chameth.com/how-i-use-tailscale/&#34;&gt;written before&lt;/a&gt; about all the different ways I use Tailscale; this is yet
another new way it’s solved a problem for me.&lt;/p&gt;
&lt;div class=&#34;footnotes&#34; role=&#34;doc-endnotes&#34;&gt;
&lt;hr/&gt;
&lt;ol&gt;
&lt;li id=&#34;fn:1&#34;&gt;
&lt;p&gt;I’m not a fan of video tutorials at the best of times, but especially not when I want to do something as quickly as possible so I can get back to &lt;del&gt;my addiction&lt;/del&gt; growing the factory. &lt;a class=&#34;footnote-backref&#34; href=&#34;#fnref:1&#34; role=&#34;doc-backlink&#34;&gt;↩︎&lt;/a&gt;&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;/div&gt;
</content>
    </entry>
    <entry>
        <title>Avoiding the Consequences of Dumb Laws with Tailscale</title>
        <link href="https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/"/>
        <updated>2025-09-30T00:00:00Z</updated>
        <id>https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/</id>
        <content xml:lang="en" type="html">&lt;p&gt;More and more sites are implementing privacy-invading age checks or just
completely blocking the UK thanks to the &lt;a href=&#34;https://www.legislation.gov.uk/ukpga/2023/50/contents&#34;&gt;Online Safety Act&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;Protecting kids from &lt;em&gt;some&lt;/em&gt; content online is certainly a noble goal, but
the asinine guidance from Ofcom, threats of absolutely disproportionate fines,
and the stupidly broad categories of content have resulted in companies just
giving up or going through a tick-box exercise that offers very little
protection but lots of inconvenience and a complete invasion of privacy.&lt;/p&gt;
&lt;p&gt;Instead of uploading my ID to some third party company, I’ve taken to proxying
my traffic through to a country that doesn’t have such stupid laws. Thankfully,
Tailscale makes this really easy. I’ve discussed &lt;a href=&#34;https://chameth.com/how-i-use-tailscale/&#34;&gt;how I use Tailscale&lt;/a&gt;
before, but not really covered &lt;em&gt;app connectors&lt;/em&gt;. I find Tailscale’s description
of these pretty confusing, but they basically amount to automatic, DNS-based
subnet routing configurations (or, to put it another way, a per-website exit
node). You can safely ignore all references to ‘SaaS apps’ in their docs.&lt;/p&gt;
&lt;p&gt;I create a custom app connector, and give it the domains to be included:&lt;/p&gt;
&lt;figure class=&#34;image full&#34;&gt;
  &lt;picture&gt;
      &lt;source srcset=&#34;https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/apps.avif&#34; type=&#34;image/avif&#34;/&gt;
      &lt;source srcset=&#34;https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/apps.webp&#34; type=&#34;image/webp&#34;/&gt;
      &lt;img src=&#34;https://chameth.com/avoiding-the-consequences-of-dumb-laws-with-tailscale/apps.png&#34; alt=&#34;Screenshot of the app section in the Tailscale admin console. It shows a table with two entries: &amp;#39;reddit&amp;#39; and &amp;#39;bluesky&amp;#39;. Each entry has a list of domain names like &amp;#39;*.reddit.com, *.reddit.it&amp;#39;.&#34; loading=&#34;lazy&#34; width=&#34;1151&#34; height=&#34;397&#34;/&gt;
  &lt;/picture&gt;
  &lt;figcaption&gt;&lt;p&gt;App configuration in the Tailscale admin console&lt;/p&gt;
&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Tailscale then magically resolves those domains, and has the ‘connector’
advertise routes for them. Any client that accepts routes will start sending
requests to the connector, which passes them onto the Internet at large. Any
other traffic is left alone, unlike when you use an exit node.&lt;/p&gt;
&lt;p&gt;The special bit here is how you can specify wildcard domains. Tailscale proxies
the DNS requests from clients (so it can inject responses for nodes on your
tailnet), which means it can dynamically update the routes as you resolve new
domains. I tried to set this up more manually, and quickly came unstuck: despite
using the same DNS servers, my server and my desktop would get different responses
for the same query as it varied by geography. Trying to get the full set of
IPs (and keeping them updated) would have been a nightmare. Tailscale expanding
the wildcards nicely sidesteps all of that.&lt;/p&gt;
&lt;p&gt;At first I was just proxying the traffic to one of my servers, but just today
I added a new connector for Imgur and found I was still blocked, just for
different reasons. They not only block my entire country but also a load
of known datacenter IP ranges. Hmph. I fixed this by hacking up a new side
project: &lt;a href=&#34;https://github.com/csmith/tsv&#34;&gt;tsv&lt;/a&gt;. It’s a simple Go app that accepts
traffic from the tailnet (advertising itself as both an app connector and an
exit node), and passes it on to another VPN.&lt;/p&gt;
&lt;p&gt;There are lots of other ways you could accomplish this, but this makes it so
all my devices can still access services without any additional configuration.
As long as Tailscale is installed, the Internet will still work as it’s meant
to, without all the nonsense. If I come across a site that doesn’t work, adding
it is trivial: I just make a new app connector in Tailscale.&lt;/p&gt;
&lt;p&gt;Obvious disclaimer: the laws in the UK are binding on the service providers,
not the end user. Doing this sort of thing in other countries might be illegal.
I don’t know; do your own research! Also all of this is a workaround
for something that should be fixed at a legislative level, but I’m not holding
my breath.&lt;/p&gt;
</content>
    </entry>
</feed>
