<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Aashish K C</title><description>Writing and field notes from Aashish K C, a software engineer in Kathmandu, Nepal.</description><link>https://kcaashish.com/</link><language>en</language><lastBuildDate>Mon, 21 Sep 2026 16:57:35 GMT</lastBuildDate><atom:link href="https://kcaashish.com/rss.xml" rel="self" type="application/rss+xml"/><item><title>Ten thousand steps, give or take four thousand</title><link>https://kcaashish.com/field-notes/ten-thousand-steps-give-or-take/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/ten-thousand-steps-give-or-take/</guid><description>Set up Step Up App on a whim. Five of us in it now, one on Android, so it had to run on both. I&apos;d tell you ten thousand a day. My average is six, and the app shows it to four other people, which is the only reason I know. One of them is past four hundred and twenty thousand steps for September with…</description><pubDate>Mon, 21 Sep 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Set up &lt;a href=&quot;https://thestepupapp.com&quot;&gt;Step Up App&lt;/a&gt; on a whim. Five of us in it now, one on Android,
so it had to run on both.&lt;/p&gt;
&lt;p&gt;I’d tell you ten thousand a day. My average is six, and the app shows it to four other people,
which is the only reason I know.&lt;/p&gt;
&lt;p&gt;One of them is past four hundred and twenty thousand steps for September with nine days still to
go. That’s twenty thousand a day, every day. I’m not catching him.&lt;/p&gt;</content:encoded><category>Field note</category><category>walking</category><category>life</category></item><item><title>Twelve containers on a laptop I&apos;d stopped using</title><link>https://kcaashish.com/writings/twelve-containers-on-a-laptop/</link><guid isPermaLink="true">https://kcaashish.com/writings/twelve-containers-on-a-laptop/</guid><description>Twelve containers on a 2017 Dell Inspiron that had sat unopened for three years. What the path layout has to look like, why the auto-updater is commented out, and why the certificate setup I built first got thrown away.</description><pubDate>Sat, 12 Sep 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This is the first version of a home media server, and it’s shaped by its hardware.&lt;/p&gt;
&lt;p&gt;The machine is a Dell Inspiron 7567 I bought in 2017 and hadn’t opened in three years. It wasn’t
broken, it had just been superseded, and it had reached the stage where it either became
something or went in a drawer permanently. So it got a fresh Debian install and this.&lt;/p&gt;
&lt;p&gt;Nothing here was chosen for the job; it was chosen for the machine I happened to have. When it
moves to hardware picked on purpose, some of these answers change, and I’ve tried to be clear
about which ones.&lt;/p&gt;
&lt;p&gt;What it does: media library and playback, audiobooks, a request interface for the people who use
it, automated library management, and remote access. Twelve containers, one compose file, one
bridge network.&lt;/p&gt;
&lt;p&gt;The whole thing is at &lt;a href=&quot;https://github.com/kcaashish/arrstack&quot;&gt;kcaashish/arrstack&lt;/a&gt;, with an
&lt;code&gt;.env.example&lt;/code&gt; and a &lt;code&gt;Caddyfile.example&lt;/code&gt; in place of my real ones.&lt;/p&gt;
&lt;h2 id=&quot;the-decision-everything-else-depends-on&quot;&gt;The decision everything else depends on&lt;/h2&gt;
&lt;p&gt;The single most consequential thing in the file is not a service. It’s how paths are mounted.&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;sonarr&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;  volumes&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999&quot;&gt;    -&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ${MEDIA_DIR}/appdata/sonarr:/config&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999&quot;&gt;    -&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ${MEDIA_DIR}/library:/data&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One mount, not two. I ran it as two for weeks, and it was wrong in a way that looks entirely
correct.&lt;/p&gt;
&lt;p&gt;This matters because of how Sonarr and Radarr import files. Given a finished download and a
library to file it into, they hardlink: the file gets a second name in the library, occupies no
additional space, and keeps seeding under its original one. Failing that they copy, and every
file exists twice on disk. On a laptop with one disk, that’s the difference between the thing
being viable and not.&lt;/p&gt;
&lt;p&gt;The condition is usually stated as “both paths on the same filesystem.” That’s necessary and it
isn’t sufficient. What the kernel compares is the mount:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;c&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#393A34&quot;&gt;error &lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#AB5959&quot;&gt; -&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt;EXDEV&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;if&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt; (&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt;old_path.mnt &lt;/span&gt;&lt;span style=&quot;color:#AB5959&quot;&gt;!=&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; new_path.mnt&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;    goto&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; out_dput&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;do_linkat&lt;/code&gt; is looking at vfsmount pointers, not device numbers. Every &lt;code&gt;-v&lt;/code&gt; line in a compose
file creates its own bind mount, so two volume lines are two mounts regardless of where they
came from on the host or what they’re called inside the container.&lt;/p&gt;
&lt;p&gt;Which is exactly what my first version did:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A0ADA0&quot;&gt;# what I had, and what most guides show&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ${MEDIA_DIR}/torrents:/data/torrents&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999&quot;&gt;-&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ${MEDIA_DIR}/media/tv:/data/media/tv&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both under &lt;code&gt;/data&lt;/code&gt;. Both from one directory on the host. Both on one ext4 volume, which the
container will confirm if you ask it:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;txt&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ docker exec sonarr mount | grep /data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;/dev/mapper/inspiring--dell--007--vg-root on /data/torrents type ext4 (rw,relatime,errors=remount-ro)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;/dev/mapper/inspiring--dell--007--vg-root on /data/media/tv type ext4 (rw,relatime,errors=remount-ro)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;$ docker exec sonarr sh -c &amp;#39;touch /data/torrents/.hltest &amp;amp;&amp;amp;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    ln /data/torrents/.hltest /data/media/tv/.hltest&amp;#39;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;ln: failed to create hard link &amp;#39;/data/media/tv/.hltest&amp;#39; =&amp;gt; &amp;#39;/data/torrents/.hltest&amp;#39;: Cross-device link&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same volume, same filesystem, refused. Every import had been quietly copying, and nothing
anywhere said so. You find out from &lt;code&gt;df&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The fix is one directory level. &lt;code&gt;appdata&lt;/code&gt; holds each service’s configuration and shouldn’t be
visible to anything but its own container, so it can’t sit inside the tree that gets mounted.
Everything that does get mounted moves under &lt;code&gt;library/&lt;/code&gt;, which is then mounted once:&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://kcaashish.com/diagrams/mount-layout.svg&quot; width=&quot;660&quot; height=&quot;385&quot; alt=&quot;Two bind mounts versus one, and where the hardlink fails. In the first arrangement, the torrent directory and the television directory are bind mounted separately into the container. They land under a shared /data prefix and sit on one filesystem, but because they are two mount points the kernel refuses a hardlink between them with EXDEV. In the second, a single library directory is mounted once at /data and contains both, so a hardlink between them stays inside one mount and succeeds. The appdata directory is kept outside library so that mounting library does not expose every service&apos;s configuration.&quot; /&gt;&lt;/figure&gt;
&lt;p&gt;The container-side paths didn’t change, so nothing inside the applications needed
reconfiguring. It was a directory move and two edited lines.&lt;/p&gt;
&lt;p&gt;The services that don’t hardlink take narrower mounts. qBittorrent only needs the download
directory, Jellyfin only needs the media directory, and Bazarr only needs to find the files
Sonarr tells it about:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;bazarr&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;  volumes&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999&quot;&gt;    -&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ${MEDIA_DIR}/library/media:/data/media&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That last one is a different requirement wearing similar clothes. Bazarr isn’t linking
anything, so mount count is irrelevant to it; it just has to agree that &lt;code&gt;/data/media/tv&lt;/code&gt; names
the same directory Sonarr meant when it reported a path. Container path strings have to match.
Treating that as the same rule as the hardlink one is how I talked myself into the first
version.&lt;/p&gt;
&lt;h2 id=&quot;updates-pinned-tags-and-the-auto-updater-switched-off&quot;&gt;Updates: pinned tags, and the auto-updater switched off&lt;/h2&gt;
&lt;p&gt;Every image is pinned to an exact version.&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;image&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; linuxserver/sonarr:4.0.19&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;image&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; caddy:2.11.4&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;image&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ghcr.io/seerr-team/seerr:v3.4.1&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Watchtower, which would pull and restart containers automatically, is in the file and commented
out. In its place:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;wud&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;  image&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ghcr.io/getwud/wud:8.4.0&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;  environment&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999&quot;&gt;    -&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; WUD_WATCHER_LOCAL_CRON=0 12 * * *&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999&quot;&gt;    -&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; WUD_TRIGGER_DISCORD_MYDISCORD_URL=${WUD_DISCORD_WEBHOOK_URL}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;What’s Up Docker checks once a day and posts to Discord when something has a newer version. It
doesn’t touch anything. I read the release notes and bump the tag by hand.&lt;/p&gt;
&lt;p&gt;The reasoning is about failure timing. With &lt;code&gt;:latest&lt;/code&gt; and an auto-updater, a breaking change
arrives at 4am while nobody is watching, and the first symptom is someone telling you playback
is broken. With pinned tags, the same breaking change arrives when I choose, with the release
notes open, and rolling back is editing one line.&lt;/p&gt;
&lt;p&gt;The filtering is done with a label on each service:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;labels&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999&quot;&gt;  -&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;wud.tag.include=^&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt;\\&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;d+&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt;\\&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt;\\&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;d+&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt;\\&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt;\\&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;d+$&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That regex restricts notifications to plain semver tags. Without it, WUD reports every &lt;code&gt;latest&lt;/code&gt;,
&lt;code&gt;nightly&lt;/code&gt;, &lt;code&gt;develop&lt;/code&gt; and &lt;code&gt;2.11&lt;/code&gt; tag as an available update and the notifications become noise
you stop reading. Services that prefix their tags with &lt;code&gt;v&lt;/code&gt; get &lt;code&gt;^v\\d+\\.\\d+\\.\\d+$&lt;/code&gt; instead.&lt;/p&gt;
&lt;h2 id=&quot;what-caddy-fronts&quot;&gt;What Caddy fronts&lt;/h2&gt;
&lt;p&gt;Caddy fronts the three services that other people actually use:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;txt&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;http://jf.example.com {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    reverse_proxy jellyfin:8096&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;http://rq.example.com {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    reverse_proxy seerr:5055&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;http://abs.example.com {&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;    reverse_proxy audiobookshelf:80&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Note the explicit &lt;code&gt;http://&lt;/code&gt;. Caddy provisions certificates automatically by default, and naming
the scheme turns that off. For hostnames that only resolve on the LAN there’s no public
challenge to satisfy, so this is deliberate rather than an omission. Proxying by service name
works because they share &lt;code&gt;media-network&lt;/code&gt; and Docker’s embedded DNS resolves container names.&lt;/p&gt;
&lt;h2 id=&quot;the-approach-i-built-first-and-then-deleted&quot;&gt;The approach I built first and then deleted&lt;/h2&gt;
&lt;p&gt;Before the tunnel, the plan was to give the internal hostnames real certificates. That meant a
custom Caddy build:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;dockerfile&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; caddy:2.11.4-builder-alpine &lt;/span&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;AS&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; builder&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;RUN&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; xcaddy build --with github.com/caddy-dns/cloudflare&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;FROM&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; caddy:2.11.4-alpine&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;COPY&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; --from=builder /usr/bin/caddy /usr/bin/caddy&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The stock Caddy image ships with no DNS provider modules, so getting one means compiling your own
binary with &lt;code&gt;xcaddy&lt;/code&gt;. Compose built it inline and passed a scoped Cloudflare token:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;caddy&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;  build&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;    context&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#2F798A&quot;&gt; .&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;    dockerfile&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; inline-caddy.Dockerfile&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;  environment&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;    CLOUDFLARE_API_TOKEN&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ${CLOUDFLARE_API_TOKEN}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reason for all of it is the ACME challenge type. The default, HTTP-01, requires Let’s
Encrypt to reach port 80 on your server from the public internet, which internal-only hostnames
by definition can’t satisfy. DNS-01 proves control by writing a TXT record instead, so it works
for services that are never publicly reachable, and the Cloudflare DNS module is what lets
Caddy write that record.&lt;/p&gt;
&lt;p&gt;It worked. Then the remote access problem killed it.&lt;/p&gt;
&lt;h2 id=&quot;why-the-tunnel-specifically&quot;&gt;Why the tunnel, specifically&lt;/h2&gt;
&lt;p&gt;Certificates were never the hard part. Reaching the machine was.&lt;/p&gt;
&lt;p&gt;Residential connections here don’t come with a static IP and you can’t buy one. The usual answer
is dynamic DNS, which handles an address that changes but assumes there’s a publicly routable
address to point at in the first place. When the connection doesn’t give you one, port forwarding
has nothing to forward to and no amount of DNS configuration helps.&lt;/p&gt;
&lt;p&gt;A Cloudflare tunnel sidesteps the whole question by reversing the direction:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;cloudflared&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;  command&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; tunnel --no-autoupdate run&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;  environment&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;    TUNNEL_TOKEN&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ${CLOUDFLARE_TUNNEL_TOKEN}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The daemon dials out to Cloudflare and holds the connection open. Traffic arrives back down a
link the server established itself. Nothing is forwarded, the router is untouched, and there’s
no inbound port to expose or protect.&lt;/p&gt;
&lt;p&gt;Which then made the custom Caddy build pointless. TLS now terminates at Cloudflare’s edge, so
nothing on the box needs a certificate, and Caddy drops back to the stock image serving plain
&lt;code&gt;http://&lt;/code&gt; internally. The &lt;code&gt;CLOUDFLARE_API_TOKEN&lt;/code&gt; line and the &lt;code&gt;inline-caddy.Dockerfile&lt;/code&gt;
reference are still sitting commented out in my compose file.&lt;/p&gt;
&lt;p&gt;That’s the shape of the decision worth recording. The first approach wasn’t wrong and it didn’t
fail. Solving a different problem properly made it unnecessary, and the work I threw away was
the work that showed me which problem actually mattered.&lt;/p&gt;
&lt;h2 id=&quot;three-ways-in-on-purpose&quot;&gt;Three ways in, on purpose&lt;/h2&gt;
&lt;p&gt;The machine has no desktop. Nothing on it was ever reachable at &lt;code&gt;localhost&lt;/code&gt;, the setup wizards
included, so remote access wasn’t a feature to add once it was working. It was a precondition
for configuring anything at all.&lt;/p&gt;
&lt;p&gt;That’s what Tailscale does here, and it’s the one piece of this that isn’t in the compose file
at all. It installs on the host rather than as a container, joins the machine to a WireGuard
mesh, and gives it an address that works from my Mac whether I’m on the same network or not.
Sonarr, Radarr, Prowlarr, qBittorrent and the rest are administered entirely over that,
including their first run.&lt;/p&gt;
&lt;p&gt;The two remote paths aren’t redundant, they’re aimed at different people. The tunnel exists
because the people who use Jellyfin and Seerr can’t reasonably be asked to join a tailnet.
Tailscale exists because the alternative is putting admin panels on the public internet in
order to configure them. The LAN is just what’s left when I’m sitting next to the thing.&lt;/p&gt;
&lt;aside style=&quot;border-left:3px solid #9a5a08;padding:0.25rem 0 0.25rem 1rem;margin:1.5rem 0&quot;&gt;&lt;p style=&quot;font-weight:600;margin:0 0 0.5rem&quot;&gt;&lt;strong&gt;The part I&amp;#39;d least want to defend&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;The admin interfaces aren’t in Caddy at all. Sonarr, Radarr, Prowlarr, Bazarr, qBittorrent and
WUD publish their ports straight to the host, which means on every interface the host has, and
I reach them over Tailscale rather than through the proxy. What actually keeps them private is
that the connection has no inbound public route in the first place, and that Tailscale decides
which devices are on the tailnet. The second of those I chose. The first is a property of my
ISP, and a boundary I didn’t build is not one I should be relying on.&lt;/p&gt;&lt;/aside&gt;
&lt;h2 id=&quot;three-things-that-cost-me-time&quot;&gt;Three things that cost me time&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;The two Jellyfin images disagree about &lt;code&gt;/config&lt;/code&gt;.&lt;/strong&gt; LinuxServer keeps the XML configuration
at the top level of it; the official image expects that one directory further down and treats
&lt;code&gt;/config&lt;/code&gt; itself as data. Jellyfin 10.11 drops a marker file in the directories it owns and
refuses to start when it finds the wrong one, so swapping the image against a directory the
other one had already initialised produces this and then a crash loop:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;txt&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;Unhandled exception. System.InvalidOperationException: Expected to find only&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;.jellyfin-data but found marker for /config/.jellyfin-config.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A fresh &lt;code&gt;/config&lt;/code&gt; works with either image, which is why it reads as though the swap broke
something that used to work. The rule I’d actually give myself is not “use the linuxserver
image.” It’s that image lineage can’t change under a &lt;code&gt;/config&lt;/code&gt; that has already been claimed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hardware transcoding needs the device passed through.&lt;/strong&gt;&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;jellyfin&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;  devices&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#999999&quot;&gt;    -&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; /dev/dri:/dev/dri&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is what makes an old laptop workable at all. Without it every transcode is software, the
CPU pins, and the fans announce it from another room. With the integrated GPU exposed, transcodes
are cheap. It’s one line, and without it this machine wouldn’t be up to the job.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Two different ways to not run as root.&lt;/strong&gt; The linuxserver images take &lt;code&gt;PUID&lt;/code&gt; and &lt;code&gt;PGID&lt;/code&gt; as
environment variables and drop privileges in their own init. Audiobookshelf isn’t a linuxserver
image, so the same variables do nothing and it needs Compose’s own directive:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;yaml&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;audiobookshelf&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;  user&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;:&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;${PUID}:${PGID}&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Get this wrong and files land owned by root, which you notice weeks later when something can’t
write.&lt;/p&gt;
&lt;h2 id=&quot;whats-wrong-with-it&quot;&gt;What’s wrong with it&lt;/h2&gt;
&lt;p&gt;Being honest about a first pass is most of its value.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One disk, no redundancy, no backups.&lt;/strong&gt; The &lt;code&gt;appdata&lt;/code&gt; directory holds every service’s
configuration and database and is backed up nowhere. This is the most embarrassing item on the
list and the cheapest to fix.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Media lives under &lt;code&gt;/home&lt;/code&gt;.&lt;/strong&gt; Which is what makes a single mount possible, and also means the
library shares a filesystem with the operating system. Fine at this size, wrong as a permanent
arrangement. On the next box, storage gets its own filesystem and the layout gets recreated
there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;depends_on&lt;/code&gt; doesn’t mean ready.&lt;/strong&gt; It orders startup and nothing more. Compose starts Sonarr
the moment the qBittorrent container exists, not when it’s accepting connections, so a cold boot
produces a spread of connection errors that resolve themselves. Health checks with
&lt;code&gt;condition: service_healthy&lt;/code&gt; would fix it properly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It’s a nine-year-old laptop.&lt;/strong&gt; Lid behaviour, thermals, and a 2017 battery now serving as an
accidental UPS. It works, but nothing about the chassis was designed to run continuously, and
the failure I should plan for is hardware rather than software.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;WUD holds the Docker socket.&lt;/strong&gt; Read-only would be enough for what it does, and it currently
has more than it needs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;No monitoring.&lt;/strong&gt; I know something broke when someone tells me. There’s no metrics, no uptime
check, no alerting beyond update notices.&lt;/p&gt;
&lt;h2 id=&quot;what-the-next-version-looks-like&quot;&gt;What the next version looks like&lt;/h2&gt;
&lt;p&gt;Real hardware, which turns most of the list above from a constraint into a design decision.&lt;/p&gt;
&lt;p&gt;The path layout stays exactly as it is. That’s the one decision here I’m confident survives the
hardware.&lt;/p&gt;</content:encoded><category>Writing</category><category>homelab</category><category>docker</category><category>caddy</category><category>self-hosted</category></item><item><title>A homelab is a very expensive way to learn that DNS works</title><link>https://kcaashish.com/field-notes/a-homelab-is-an-expensive-dns-lesson/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/a-homelab-is-an-expensive-dns-lesson/</guid><description>There is a particular kind of satisfaction in making an old computer do something useful. Then you add Caddy. Cloudflare. Tailscale. Jellyfin. Monitoring. Containers. Another container to manage the containers. Eventually you&apos;re spending an evening fixing a problem that wouldn&apos;t exist if you had…</description><pubDate>Sat, 12 Sep 2026 11:59:00 GMT</pubDate><content:encoded>&lt;p&gt;There is a particular kind of satisfaction in making an old computer do something useful.&lt;/p&gt;
&lt;p&gt;Then you add Caddy. Cloudflare. Tailscale. Jellyfin. Monitoring. Containers. Another container
to manage the containers.&lt;/p&gt;
&lt;p&gt;Eventually you’re spending an evening fixing a problem that wouldn’t exist if you had just paid
for the service.&lt;/p&gt;
&lt;p&gt;That’s part of the point.&lt;/p&gt;</content:encoded><category>Field note</category><category>homelab</category><category>self-hosted</category><category>devops</category></item><item><title>The app that would not let go</title><link>https://kcaashish.com/writings/the-app-that-would-not-let-go/</link><guid isPermaLink="true">https://kcaashish.com/writings/the-app-that-would-not-let-go/</guid><description>A relative&apos;s phone had an app on it that could not be uninstalled, could not be found by name, and blocked a factory reset. Finding it meant searching by what it could do rather than what it was called.</description><pubDate>Sat, 29 Aug 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;An older relative came to me with a phone that had stopped behaving. The battery was draining
in hours. Google Play Protect had flagged something and claimed to have dealt with it. But the
app was still there in Settings, and it would not come off.&lt;/p&gt;
&lt;p&gt;Every time he opened the uninstall screen, the screen closed itself. He tried a factory reset.
The reset screen closed itself too.&lt;/p&gt;
&lt;p&gt;By the time it reached me, he had been living with it for six days. He had installed it after
seeing an advertisement on TikTok for an app called Mox, which promised free IPTV, films and
series. The link took him outside the Play Store to an APK he installed by hand.&lt;/p&gt;
&lt;p&gt;This is what it took to find it, why the obvious approach failed, and why the phone kept
closing its own settings screens.&lt;/p&gt;
&lt;h2 id=&quot;the-first-thing-i-tried-and-why-it-found-nothing&quot;&gt;The first thing I tried, and why it found nothing&lt;/h2&gt;
&lt;p&gt;The app was called Mox, so:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; list&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; packages&lt;/span&gt;&lt;span style=&quot;color:#AB5959&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; grep&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -i&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; mox&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing.&lt;/p&gt;
&lt;p&gt;That result is the whole lesson of this case, so it’s worth being precise about why. The name
you see in Settings is the app’s &lt;strong&gt;label&lt;/strong&gt;, a human-readable string stored in the APK’s
resources, which the developer can set to anything. The &lt;strong&gt;package name&lt;/strong&gt; is a separate
identifier, and nothing requires the two to resemble each other.&lt;/p&gt;
&lt;p&gt;Here, the label said Mox. The package name was &lt;code&gt;com.nzr.truove&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Searching by name cannot find an app that does not want to be found. I widened the net anyway,
in case something turned up in a component name or a file path:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; list&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; packages&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -d&lt;/span&gt;&lt;span style=&quot;color:#A0ADA0&quot;&gt;      # disabled packages&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; list&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; packages&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -u&lt;/span&gt;&lt;span style=&quot;color:#A0ADA0&quot;&gt;      # includes uninstalled packages with retained data&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; list&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; packages&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -f&lt;/span&gt;&lt;span style=&quot;color:#A0ADA0&quot;&gt;      # includes the APK path&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; dumpsys&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; package&lt;/span&gt;&lt;span style=&quot;color:#AB5959&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; grep&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -i&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; mox&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Still nothing matching “mox”. The &lt;code&gt;-u&lt;/code&gt; list did contain &lt;code&gt;com.nzr.truove&lt;/code&gt;, but at that point it
was one unremarkable string among three hundred. I had no reason to look at it twice.&lt;/p&gt;
&lt;p&gt;I also checked for a hidden work profile, which is a common place for things to sit out of
sight:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; list&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; users&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One user. Nothing hiding there either.&lt;/p&gt;
&lt;p&gt;Name-based search was exhausted.&lt;/p&gt;
&lt;h2 id=&quot;the-pivot-search-by-capability-instead&quot;&gt;The pivot: search by capability instead&lt;/h2&gt;
&lt;p&gt;If you cannot find an app by what it is called, find it by what it can &lt;em&gt;do&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;This turns out to be a much better approach in general, because the set of permissions that
make an app genuinely dangerous is small, and Android will list every holder of them on
request. Three commands cover it:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; settings&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; get&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; secure&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; enabled_accessibility_services&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; settings&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; get&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; secure&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; enabled_notification_listeners&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; dumpsys&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; device_policy&lt;/span&gt;&lt;span style=&quot;color:#AB5959&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; head&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -40&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first one ended the search:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;com.openai.chatgpt/com.openai.feature.conversations.screencontext.ConversationScreenAccessibilityService&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;:com.nzr.truove/com.nzr.truove.muuozzscovxd&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two entries. The first belongs to a known app and has a descriptive class name. The second is
an unrecognised package whose service class is &lt;code&gt;muuozzscovxd&lt;/code&gt;, a random consonant string.&lt;/p&gt;
&lt;p&gt;Real developers name a service something like &lt;code&gt;ScreenReaderService&lt;/code&gt;, because they have to
maintain it. Randomised class names exist to defeat signature-based scanning. An unknown
package holding accessibility rights under an obfuscated name is not an ambiguous finding.&lt;/p&gt;
&lt;h2 id=&quot;why-the-phone-kept-closing-its-own-screens&quot;&gt;Why the phone kept closing its own screens&lt;/h2&gt;
&lt;p&gt;An enabled accessibility service is the most powerful thing a non-system app can hold on
Android. It is designed for screen readers and switch access, so it grants two capabilities
together:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;read the entire contents of every screen, including text typed into password fields&lt;/li&gt;
&lt;li&gt;generate synthetic taps, swipes and gestures&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The second half is what my relative had been fighting for six days. The app could see which
screen was open. When that screen was its own uninstall page, or the factory reset page, it
pressed Back, a real input event, indistinguishable from a finger.&lt;/p&gt;
&lt;aside style=&quot;border-left:3px solid #9a5a08;padding:0.25rem 0 0.25rem 1rem;margin:1.5rem 0&quot;&gt;&lt;p style=&quot;font-weight:600;margin:0 0 0.5rem&quot;&gt;&lt;strong&gt;I expected the wrong answer&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;The usual cause of an un-removable Android app is &lt;strong&gt;device administrator&lt;/strong&gt; rights, which let an
app formally block its own uninstall. So I checked:&lt;/p&gt;&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; dumpsys&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; device_policy&lt;/span&gt;&lt;span style=&quot;color:#AB5959&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; head&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -40&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;No device administrators registered. It had no formal protection at all. It was simply
watching the screen and tapping Back faster than a person could finish the flow.&lt;/p&gt;&lt;/aside&gt;
&lt;p&gt;That distinction matters practically. ADB commands go to the package manager directly. There is
no screen for the app to detect and no button for it to press, so it cannot interfere. If you
are stuck using the phone’s own UI, the equivalent move is to revoke the accessibility
permission first. That removes the app’s hands before you try to remove the app.&lt;/p&gt;
&lt;p&gt;The same dump corroborated the finding independently:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;accessibility services: 2 apps&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  0: com.openai.chatgpt&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;  1: com.nzr.truove&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notification access, meanwhile, was clean. Truecaller, Samsung Routines, the launcher, nothing
unexpected. Useful negative evidence: whatever this was, it was not reading notifications.&lt;/p&gt;
&lt;h2 id=&quot;what-it-actually-had&quot;&gt;What it actually had&lt;/h2&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; dumpsys&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; package&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; com.nzr.truove&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#AB5959&quot;&gt;  |&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; grep&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -iE&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;versionName|firstInstallTime|installerPackageName|requested permissions&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -A&lt;/span&gt;&lt;span style=&quot;color:#2F798A&quot;&gt; 25&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The record was unambiguous.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;installerPackageName&lt;/code&gt; was absent and &lt;code&gt;packageSource&lt;/code&gt; was &lt;code&gt;0&lt;/code&gt;, confirming a sideload rather
than a Play Store install. The version string was &lt;code&gt;2.184.44 suupenavuvubgfpxk&lt;/code&gt;, with a random
token appended to a plausible-looking version number. It declared around twenty-five custom
permissions under a &lt;code&gt;com.gbu.efgypmb&lt;/code&gt; namespace that corresponds to no real package. Anti-analysis noise, nothing more.&lt;/p&gt;
&lt;p&gt;Then the runtime grants:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;plaintext&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span&gt;android.permission.READ_SMS:     granted=true&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;android.permission.CAMERA:       granted=true&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span&gt;android.permission.RECORD_AUDIO: granted=true&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;READ_SMS&lt;/code&gt; is the line that changed the nature of the problem.&lt;/p&gt;
&lt;p&gt;Screen reading gives you what someone types into a login form. &lt;code&gt;READ_SMS&lt;/code&gt; gives you the
one-time passcode sent to confirm it. Held simultaneously, they collapse two-factor
authentication into one factor, because both factors arrive at the same compromised device.
Every banking app on that phone had been operating inside that window.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://kcaashish.com/diagrams/two-factor-collapse.svg&quot; width=&quot;660&quot; height=&quot;282&quot; alt=&quot;Both authentication factors arriving at one compromised device. The first factor, a password typed into a login form, is captured by the accessibility service reading the screen. The second factor, a one-time passcode sent by SMS, is captured by the READ_SMS permission. Both are held by com.nzr.truove on the same handset, so the two factors converge on a single app.&quot; /&gt;&lt;/figure&gt;
&lt;p&gt;&lt;code&gt;firstInstallTime&lt;/code&gt; gave the window’s start: 23 August, 13:24. That single field turned out to
be the most valuable thing in the whole dump, because it is what a bank’s fraud team needs in
order to know which transactions to review.&lt;/p&gt;
&lt;h2 id=&quot;checking-whether-it-came-alone&quot;&gt;Checking whether it came alone&lt;/h2&gt;
&lt;p&gt;Malware often installs companions, so before removing anything I checked whether the timeline
had other entries:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; path&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; com.nzr.truove&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; list&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; packages&lt;/span&gt;&lt;span style=&quot;color:#AB5959&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; grep&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -i&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;gbu\|efgypmb&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The APK sat under &lt;code&gt;/data/app/&lt;/code&gt;, which is the ordinary third-party area. That was good news: a
package under &lt;code&gt;/system/&lt;/code&gt; or &lt;code&gt;/product/&lt;/code&gt; would have been a privileged preinstall and a much
worse problem. The phantom permission namespace matched no installed package.&lt;/p&gt;
&lt;p&gt;I then checked install dates on every other unexplained package on the device. One was
timestamped 2008, the factory image date, so a vendor preinstall. The rest were from ten months
earlier. Nothing else appeared on 23 August. Single package, single incident.&lt;/p&gt;
&lt;h2 id=&quot;removal-and-confirming-it&quot;&gt;Removal, and confirming it&lt;/h2&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pull&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; /data/app/~~.../base.apk&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ~/evidence.apk&lt;/span&gt;&lt;span style=&quot;color:#A0ADA0&quot;&gt;     # keep a copy first&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; disable-user&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; --user&lt;/span&gt;&lt;span style=&quot;color:#2F798A&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; com.nzr.truove&lt;/span&gt;&lt;span style=&quot;color:#A0ADA0&quot;&gt;    # contain, reversible&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; uninstall&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; --user&lt;/span&gt;&lt;span style=&quot;color:#2F798A&quot;&gt; 0&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; com.nzr.truove&lt;/span&gt;&lt;span style=&quot;color:#A0ADA0&quot;&gt;       # remove app and data&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;disable-user&lt;/code&gt; switches an app off immediately without deleting it, which is useful when you
want the threat neutralised now but the evidence intact.&lt;/p&gt;
&lt;p&gt;Verification is where it is easy to fool yourself:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; list&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; packages&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -u&lt;/span&gt;&lt;span style=&quot;color:#AB5959&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; grep&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; nzr&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; settings&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; get&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; secure&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; enabled_accessibility_services&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; find&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; /sdcard&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -iname&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;*.apk&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;-u&lt;/code&gt; flag is essential. A plain &lt;code&gt;pm list packages&lt;/code&gt; will report clean even when the package
was uninstalled with its data directory deliberately retained. Without &lt;code&gt;-u&lt;/code&gt; you cannot tell
“removed” from “removed, but it kept everything”.&lt;/p&gt;
&lt;p&gt;All three came back clean. The accessibility list was down to the one legitimate entry, and the
original APK was no longer sitting in Downloads waiting to be run again.&lt;/p&gt;
&lt;h2 id=&quot;what-i-would-do-first-next-time&quot;&gt;What I would do first next time&lt;/h2&gt;
&lt;p&gt;Knowing the answer, two commands would have found this in under a minute.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Audit the privileged grants before enumerating anything.&lt;/strong&gt; The three &lt;code&gt;settings&lt;/code&gt; and &lt;code&gt;dumpsys&lt;/code&gt;
reads above produce a list short enough to check by eye, and anything hostile enough to matter
is almost certainly in it. I spent most of my time enumerating packages, which was the wrong
end of the problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;List everything that did not come from the Play Store:&lt;/strong&gt;&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;bash&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;adb&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; shell&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; pm&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; list&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; packages&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -i&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -3&lt;/span&gt;&lt;span style=&quot;color:#AB5959&quot;&gt; |&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; grep&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -v&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt; &amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;installer=com.android.vending&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On an ordinary consumer phone this should be close to empty. A sideloaded app nobody remembers
installing is worth a hard look.&lt;/p&gt;
&lt;h2 id=&quot;the-part-that-isnt-technical&quot;&gt;The part that isn’t technical&lt;/h2&gt;
&lt;p&gt;Removing the app does not undo the exposure. For six days, credentials and SMS passcodes had
both been readable on that device, and no amount of correct ADB work changes that. The actual
remediation happened elsewhere: credentials reset from a different, clean device, the bank
notified with the exposure window so they could review activity against it, and passcodes
rotated on every account that had been opened on the phone during those six days.&lt;/p&gt;
&lt;p&gt;The triage tells you what happened and when. It is the beginning of the response, not the end
of it.&lt;/p&gt;
&lt;p&gt;There is also a design observation buried in this, which I keep turning over. Accessibility
services exist for a genuinely good reason. They are how blind users operate a phone at all.
The capability cannot be removed without removing that. But the same grant that lets a screen
reader describe an interface lets hostile software operate it, and a person tapping through a
consent dialog on a phone they got from a TikTok advertisement is not equipped to tell those
apart. The permission model is technically sound and, for the person actually holding the
phone, close to useless.&lt;/p&gt;</content:encoded><category>Writing</category><category>android</category><category>security</category><category>adb</category><category>debugging</category></item><item><title>A fast website is mostly a website that doesn&apos;t do much</title><link>https://kcaashish.com/field-notes/a-fast-site-is-one-that-does-less/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/a-fast-site-is-one-that-does-less/</guid><description>I&apos;ve spent an unreasonable amount of time looking at why some personal websites feel instantaneous. The answer is less exotic than I expected. Don&apos;t ship what you don&apos;t need. Don&apos;t make the browser assemble what could have been a document. Don&apos;t add JavaScript just because the framework makes it…</description><pubDate>Wed, 19 Aug 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I’ve spent an unreasonable amount of time looking at why some personal websites feel
instantaneous.&lt;/p&gt;
&lt;p&gt;The answer is less exotic than I expected.&lt;/p&gt;
&lt;p&gt;Don’t ship what you don’t need. Don’t make the browser assemble what could have been a document.
Don’t add JavaScript just because the framework makes it easy.&lt;/p&gt;
&lt;p&gt;There is something satisfying about a website that arrives almost completely finished.&lt;/p&gt;</content:encoded><category>Field note</category><category>performance</category><category>web</category></item><item><title>Debian doesn&apos;t install sudo if you set a root password</title><link>https://kcaashish.com/field-notes/debian-skips-sudo-with-a-root-password/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/debian-skips-sudo-with-a-root-password/</guid><description>sudo: command not found on a fresh install isn&apos;t a broken image. If you set a root password during setup, Debian assumes you&apos;ll use su and skips the package entirely. Leave that field blank and it installs sudo and adds you to the group for you. Log out fully and back in. Group membership is read…</description><pubDate>Wed, 15 Jul 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;&lt;code&gt;sudo: command not found&lt;/code&gt; on a fresh install isn’t a broken image. If you set a root password
during setup, Debian assumes you’ll use &lt;code&gt;su&lt;/code&gt; and skips the package entirely. Leave that field
blank and it installs sudo and adds you to the group for you.&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x: auto&quot; data-language=&quot;sh&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;su&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; -&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;apt&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; update&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt; &amp;amp;&amp;amp;&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; apt&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; install&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; sudo&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -y&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;usermod&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -aG&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; sudo&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; your_username&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;exit&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Log out fully and back in. Group membership is read at login, so a new shell in the same
session still won’t see it. Once you’re back in, &lt;code&gt;sudo whoami&lt;/code&gt; prints &lt;code&gt;root&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;This surprised me because I’d assumed sudo was a base package everywhere. It isn’t. It’s a
package Debian installs conditionally, based on an answer you gave several minutes earlier and
have already forgotten.&lt;/p&gt;</content:encoded><category>Field note</category><category>debian</category><category>linux</category></item><item><title>Read the standard library before reaching for a package</title><link>https://kcaashish.com/field-notes/reading-the-stdlib-first/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/reading-the-stdlib-first/</guid><description>I spent an afternoon evaluating rate limiter packages before someone pointed at golang.org/x/time/rate, which does exactly what I needed and is about two hundred lines I could actually read end to end. This keeps happening. Go&apos;s standard library and the x/ repos cover more than I expect, and the…</description><pubDate>Wed, 24 Jun 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I spent an afternoon evaluating rate limiter packages before someone pointed at &lt;code&gt;golang.org/x/time/rate&lt;/code&gt;, which does exactly what I needed and is about two hundred lines I could actually read end to end.&lt;/p&gt;
&lt;p&gt;This keeps happening. Go’s standard library and the &lt;code&gt;x/&lt;/code&gt; repos cover more than I expect, and the code is short enough to read rather than trust. Now the first step is a search there, and only then in the wider ecosystem.&lt;/p&gt;</content:encoded><category>Field note</category><category>go</category></item><item><title>Write the migration so it can run twice</title><link>https://kcaashish.com/field-notes/the-migration-that-runs-twice/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/the-migration-that-runs-twice/</guid><description>Deploys get retried. Pods restart mid-migration. Someone runs the command again because they weren&apos;t sure it took. IF NOT EXISTS on creates, IF EXISTS on drops, and a backfill written as an idempotent update rather than an insert. It costs nothing to write it this way the first time and saves an…</description><pubDate>Wed, 03 Jun 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Deploys get retried. Pods restart mid-migration. Someone runs the command again because they weren’t sure it took.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;IF NOT EXISTS&lt;/code&gt; on creates, &lt;code&gt;IF EXISTS&lt;/code&gt; on drops, and a backfill written as an idempotent update rather than an insert. It costs nothing to write it this way the first time and saves an incident later.&lt;/p&gt;
&lt;p&gt;The exception is anything destructive. Those I want to fail loudly on a second run, because a &lt;code&gt;DROP COLUMN&lt;/code&gt; that quietly succeeds twice means I’ve lost track of what state the database is in.&lt;/p&gt;</content:encoded><category>Field note</category><category>postgres</category><category>backend</category></item><item><title>Reading a book is not the same as finishing one</title><link>https://kcaashish.com/field-notes/finishing-is-a-goodreads-metric/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/finishing-is-a-goodreads-metric/</guid><description>I&apos;ve started thinking less about how many books I finish and more about whether a book changes what I notice. A book can sit half-read for months and still give you an idea you carry around for years. Finishing is a useful metric for Goodreads. It isn&apos;t necessarily a useful metric for reading.</description><pubDate>Thu, 16 Apr 2026 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I’ve started thinking less about how many books I finish and more about whether a book changes
what I notice.&lt;/p&gt;
&lt;p&gt;A book can sit half-read for months and still give you an idea you carry around for years.&lt;/p&gt;
&lt;p&gt;Finishing is a useful metric for Goodreads.&lt;/p&gt;
&lt;p&gt;It isn’t necessarily a useful metric for reading.&lt;/p&gt;</content:encoded><category>Field note</category><category>reading</category><category>life</category></item><item><title>The first kilometre is supposed to feel bad</title><link>https://kcaashish.com/field-notes/a-slower-first-kilometre/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/a-slower-first-kilometre/</guid><description>Two years of assuming a rough opening kilometre meant a rough run. It doesn&apos;t. It means I haven&apos;t warmed up yet, which is the expected state of a body that was asleep twenty minutes ago. Starting deliberately slower than feels right makes kilometres three through eight better than they&apos;ve ever…</description><pubDate>Sun, 14 Dec 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Two years of assuming a rough opening kilometre meant a rough run. It doesn’t. It means I haven’t warmed up yet, which is the expected state of a body that was asleep twenty minutes ago.&lt;/p&gt;
&lt;p&gt;Starting deliberately slower than feels right makes kilometres three through eight better than they’ve ever been. The same lesson keeps showing up at work, where the instinct to start fast is also usually wrong.&lt;/p&gt;</content:encoded><category>Field note</category><category>running</category></item><item><title>The goroutine leak was a missing cancel</title><link>https://kcaashish.com/field-notes/context-cancellation-leaks/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/context-cancellation-leaks/</guid><description>Memory climbing slowly over days, nothing obvious in the heap profile. It was in the goroutine profile instead: thousands of them parked on a channel receive that would never fire. WithTimeout allocates a timer and registers the child with its parent. Without cancel(), neither is released until the…</description><pubDate>Thu, 30 Oct 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Memory climbing slowly over days, nothing obvious in the heap profile. It was in the goroutine profile instead: thousands of them parked on a channel receive that would never fire.&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x: auto&quot; data-language=&quot;go&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#B07D48&quot;&gt;ctx&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#B07D48&quot;&gt; cancel&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt; :=&lt;/span&gt;&lt;span style=&quot;color:#B07D48&quot;&gt; context&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt;WithTimeout&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;(&lt;/span&gt;&lt;span style=&quot;color:#B07D48&quot;&gt;parent&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;,&lt;/span&gt;&lt;span style=&quot;color:#2F798A&quot;&gt; 2&lt;/span&gt;&lt;span style=&quot;color:#AB5959&quot;&gt;*&lt;/span&gt;&lt;span style=&quot;color:#B07D48&quot;&gt;time&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;.&lt;/span&gt;&lt;span style=&quot;color:#B07D48&quot;&gt;Second&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;defer&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; cancel&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;()&lt;/span&gt;&lt;span style=&quot;color:#A0ADA0&quot;&gt; // this line was the whole bug&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;WithTimeout&lt;/code&gt; allocates a timer and registers the child with its parent. Without &lt;code&gt;cancel()&lt;/code&gt;, neither is released until the timeout fires — and if the parent outlives the process, effectively never. &lt;code&gt;go vet&lt;/code&gt; catches the obvious cases; this one was behind a helper function, so it didn’t.&lt;/p&gt;</content:encoded><category>Field note</category><category>go</category><category>debugging</category></item><item><title>Paid for the year so I&apos;d stop deciding</title><link>https://kcaashish.com/field-notes/paid-for-the-year-up-front/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/paid-for-the-year-up-front/</guid><description>Bought twelve months of a training-plan app in one payment, which is not how I usually buy software. Monthly is the flexible option, and flexible is exactly the problem; it leaves a small decision sitting there every thirty days. Prepaying removes it. There&apos;s no renewal to reconsider in April when…</description><pubDate>Thu, 27 Feb 2025 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Bought twelve months of a training-plan app in one payment, which is not how I usually buy
software. Monthly is the flexible option, and flexible is exactly the problem; it leaves a
small decision sitting there every thirty days.&lt;/p&gt;
&lt;p&gt;Prepaying removes it. There’s no renewal to reconsider in April when it’s raining and I’ve
already skipped a week. The money is gone, and the only thing left to decide is whether to run.&lt;/p&gt;
&lt;p&gt;Priced against one evening out, a year of structured training isn’t a close call. Two runs in,
which is far too early to say anything about the plans themselves.&lt;/p&gt;</content:encoded><category>Field note</category><category>running</category><category>life</category></item><item><title>Alias the host, not the key</title><link>https://kcaashish.com/writings/alias-the-host-not-the-key/</link><guid isPermaLink="true">https://kcaashish.com/writings/alias-the-host-not-the-key/</guid><description>Managing several SSH keys on one machine gets much simpler once you stop thinking about keys and start inventing hostnames.</description><pubDate>Thu, 05 Oct 2023 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Two GitHub accounts on one machine is the usual reason people go looking for this, and the
usual answers are bad: per-repository &lt;code&gt;core.sshCommand&lt;/code&gt; overrides, or a script that swaps
&lt;code&gt;id_ed25519&lt;/code&gt; in and out of the agent.&lt;/p&gt;
&lt;p&gt;Neither is necessary. &lt;code&gt;~/.ssh/config&lt;/code&gt; lets you invent a hostname that only exists on your
machine and bind a key to it:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;ssh-config&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#AB5959&quot;&gt;Host&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; personal.github.com&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;  HostName&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; github.com&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;  AddKeysToAgent&lt;/span&gt;&lt;span style=&quot;color:#1E754F&quot;&gt; yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;  UseKeychain&lt;/span&gt;&lt;span style=&quot;color:#1E754F&quot;&gt; yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;  User&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; git&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;  IdentityFile&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; ~/.ssh/id_ed25519&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#AB5959&quot;&gt;Host&lt;/span&gt;&lt;span style=&quot;color:#59873A&quot;&gt; work.github.com&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;  HostName&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; github.com&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;  AddKeysToAgent&lt;/span&gt;&lt;span style=&quot;color:#1E754F&quot;&gt; yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;  UseKeychain&lt;/span&gt;&lt;span style=&quot;color:#1E754F&quot;&gt; yes&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;  User&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; git&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#1E754F&quot;&gt;  IdentityFile&lt;/span&gt;&lt;span style=&quot;color:#393A34&quot;&gt; ~/.ssh/id_ed25519_work&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Host&lt;/code&gt; is a label that means nothing outside this file. &lt;code&gt;HostName&lt;/code&gt; is where the connection
actually goes. So both entries reach github.com, and which key gets offered depends only on
which fiction you typed.&lt;/p&gt;
&lt;p&gt;Then the remote URL carries the choice:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;sh&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;git&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; clone&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; git@personal.github.com:username/repo.git&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The key selection now lives in the URL, which lives in &lt;code&gt;.git/config&lt;/code&gt;, which travels with the
clone. Nothing to remember and nothing to switch.&lt;/p&gt;
&lt;p&gt;On macOS, &lt;code&gt;UseKeychain yes&lt;/code&gt; stores the passphrase in the keychain and &lt;code&gt;AddKeysToAgent yes&lt;/code&gt; loads
the key on first use, so you type the passphrase once ever rather than once per reboot.&lt;/p&gt;
&lt;p&gt;When it doesn’t work, one flag tells you why:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;sh&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;ssh&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -vT&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; git@personal.github.com&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The verbose output names the config entry it matched and the key it offered. Almost every
failure here is one of two things: the URL still points at plain &lt;code&gt;github.com&lt;/code&gt;, or you added a
second key to an account that already has the first one. GitHub rejects a key that’s registered
to a different account with a message about repository access, which sends people looking at
permissions when the problem is the key.&lt;/p&gt;</content:encoded><category>Writing</category><category>ssh</category><category>git</category><category>macos</category></item><item><title>One SSH key, one GitHub account</title><link>https://kcaashish.com/field-notes/one-ssh-key-one-github-account/</link><guid isPermaLink="true">https://kcaashish.com/field-notes/one-ssh-key-one-github-account/</guid><description>Pushed to a repository I definitely had access to and was told I didn&apos;t. The key was the problem: GitHub only lets a key be registered once across all accounts, and mine was still attached to the other one. Nothing in the error says that. You get a message about the repository, so you go and check…</description><pubDate>Thu, 05 Oct 2023 11:59:00 GMT</pubDate><content:encoded>&lt;p&gt;Pushed to a repository I definitely had access to and was told I didn’t. The key was the
problem: GitHub only lets a key be registered once across all accounts, and mine was still
attached to the other one.&lt;/p&gt;
&lt;p&gt;Nothing in the error says that. You get a message about the repository, so you go and check the
repository, and everything there is fine.&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x: auto&quot; data-language=&quot;sh&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;ssh&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -T&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; git@github.com&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The reply names the account it just authenticated you as. When that’s the wrong name, stop
looking at permissions.&lt;/p&gt;</content:encoded><category>Field note</category><category>ssh</category><category>git</category></item><item><title>One Mac, two users, one Homebrew</title><link>https://kcaashish.com/writings/one-mac-two-users-one-homebrew/</link><guid isPermaLink="true">https://kcaashish.com/writings/one-mac-two-users-one-homebrew/</guid><description>Separating work and personal accounts on a Mac is a reasonable thing to want. macOS and Homebrew both quietly assume you won&apos;t.</description><pubDate>Tue, 19 Sep 2023 12:00:00 GMT</pubDate><content:encoded>&lt;p&gt;At some point you end up wanting two accounts on one Mac. Maybe the work you’re doing should
live somewhere your personal browser sessions and SSH keys don’t. Maybe your employer wants a
managed account and you’d rather not hand over the whole machine. Whatever the reason, the
request is modest: one laptop, two logins, a clean line between them.&lt;/p&gt;
&lt;p&gt;macOS handles the first ninety percent of that well. Separate home directories, separate
keychains, separate browser profiles, fast user switching. Then you open a terminal in the new
account and type &lt;code&gt;brew&lt;/code&gt;, and nothing happens.&lt;/p&gt;
&lt;p&gt;What follows is everything that broke, and why each thing broke, because the reasons turn out
to be more interesting than the fixes.&lt;/p&gt;
&lt;h2 id=&quot;the-core-problem-two-different-ideas-of-ownership&quot;&gt;The core problem: two different ideas of ownership&lt;/h2&gt;
&lt;p&gt;macOS is built around per-user isolation. Almost everything meaningful lives under
&lt;code&gt;~/Library&lt;/code&gt;, and a second account genuinely starts from nothing.&lt;/p&gt;
&lt;p&gt;Homebrew is built around a single shared prefix. On Apple Silicon that’s &lt;code&gt;/opt/homebrew&lt;/code&gt;, and
it is owned by whichever user installed it. Not by root, not by a group, but by a person.&lt;/p&gt;
&lt;p&gt;Those two models are individually sensible and jointly awkward. Your second account can &lt;em&gt;see&lt;/em&gt;
&lt;code&gt;/opt/homebrew&lt;/code&gt; because it’s world-readable, but it can’t write to it, and Homebrew wants to
write constantly: updating formulae, linking binaries, managing its own git repository.&lt;/p&gt;
&lt;figure&gt;&lt;img src=&quot;https://kcaashish.com/diagrams/homebrew-ownership.svg&quot; width=&quot;660&quot; height=&quot;344&quot; alt=&quot;Homebrew ownership across two macOS accounts. The personal account owns and writes to /opt/homebrew. The work account cannot write to it, but can read and execute everything in /opt/homebrew/bin. The Cellar, git repository and formulae inside the prefix remain reachable only by the owning account.&quot; /&gt;&lt;/figure&gt;
&lt;p&gt;There are three ways out and only one of them is good.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Install a second Homebrew.&lt;/strong&gt; Works, and it’s the option Homebrew’s own documentation is most
comfortable with. But you now have two package databases, two update cycles, and several
gigabytes of duplicated Xcode-adjacent dependencies on a laptop SSD. You will also, eventually,
be confused about which one you’re talking to.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Change ownership to a shared group.&lt;/strong&gt; Tempting, and it appears in a lot of forum answers. It’s
fragile. Homebrew resets permissions during some operations, and you end up re-running &lt;code&gt;chown&lt;/code&gt;
after upgrades and wondering why things broke.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Run &lt;code&gt;brew&lt;/code&gt; as the user who owns it.&lt;/strong&gt; This is the one I settled on:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;sh&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#AB5959&quot;&gt;alias&lt;/span&gt;&lt;span style=&quot;color:#B07D48&quot;&gt; brew&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;#39;&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;sudo -Hiu akc brew&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;#39;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Where &lt;code&gt;akc&lt;/code&gt; is my personal account, the one that installed Homebrew. The flags matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;-u akc&lt;/code&gt; runs as that user&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-i&lt;/code&gt; starts a login shell, so Homebrew gets the environment it expects rather than an
inherited one from the calling account&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-H&lt;/code&gt; sets &lt;code&gt;$HOME&lt;/code&gt; to the target user’s home, which is what stops Homebrew writing its cache
and logs into the wrong place&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The result is that installing a package from the work account installs it into the personal
account’s Homebrew, and both accounts use it.&lt;/p&gt;
&lt;p&gt;That last part is the piece worth understanding. Homebrew’s &lt;em&gt;management&lt;/em&gt; needs write access,
but the things it installs land in &lt;code&gt;/opt/homebrew/bin&lt;/code&gt;, which is readable and executable by
everyone on the machine. So the second account doesn’t need Homebrew at all in order to use
what Homebrew installed. It just needs the path:&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;sh&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A0ADA0&quot;&gt;# ~/.zprofile&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#AB5959&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#B07D48&quot;&gt; PATH&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;$PATH:/opt/homebrew/bin&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;.zprofile&lt;/code&gt; rather than &lt;code&gt;.zshrc&lt;/code&gt;, because this is environment setup that should happen once at
login rather than on every interactive shell.&lt;/p&gt;
&lt;aside style=&quot;border-left:3px solid #9a5a08;padding:0.25rem 0 0.25rem 1rem;margin:1.5rem 0&quot;&gt;&lt;p style=&quot;font-weight:600;margin:0 0 0.5rem&quot;&gt;&lt;strong&gt;The honest cost&lt;/strong&gt;&lt;/p&gt;&lt;p&gt;The alias requires the work account to have sudo rights, and it means the work account can
modify your personal Homebrew installation. That is a real weakening of the boundary you
created the second account to enforce. If the separation is for security rather than tidiness,
install the second Homebrew and pay the disk.&lt;/p&gt;&lt;/aside&gt;
&lt;h2 id=&quot;docker-which-fails-differently&quot;&gt;Docker, which fails differently&lt;/h2&gt;
&lt;p&gt;Docker Desktop breaks for a related but distinct reason, and the failure looks identical from
the terminal: &lt;code&gt;docker: command not found&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The application itself is in &lt;code&gt;/Applications&lt;/code&gt; and every account can launch it. But Docker
Desktop installs its CLI tools (&lt;code&gt;docker&lt;/code&gt;, &lt;code&gt;docker-compose&lt;/code&gt;, &lt;code&gt;docker-buildx&lt;/code&gt;) into the user’s
home directory at &lt;code&gt;~/.docker/bin&lt;/code&gt;, and then symlinks or path-injects them per account. The
second user gets the app and none of the commands.&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;sh&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#A0ADA0&quot;&gt;# ~/.zprofile&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#AB5959&quot;&gt;export&lt;/span&gt;&lt;span style=&quot;color:#B07D48&quot;&gt; PATH&lt;/span&gt;&lt;span style=&quot;color:#999999&quot;&gt;=&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;quot;&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt;$PATH:$HOME/.docker/bin&lt;/span&gt;&lt;span style=&quot;color:#B5695977&quot;&gt;&amp;quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Launch Docker Desktop once from the new account first, so it populates that directory. There’s
a long-running &lt;a href=&quot;https://github.com/docker/for-mac/issues/6781&quot;&gt;thread on docker/for-mac&lt;/a&gt; about
multi-user behaviour if you want the full history.&lt;/p&gt;
&lt;h2 id=&quot;dotfiles-symlink-dont-copy&quot;&gt;Dotfiles: symlink, don’t copy&lt;/h2&gt;
&lt;p&gt;The obvious move is to copy your config across. Don’t. You’ll edit one, forget the other, and
spend a month wondering why your Neovim keybindings differ depending on which account you’re
in.&lt;/p&gt;
&lt;p&gt;Symlink instead, so there’s one source of truth. There are two places that source of truth can
live, and the difference matters more than it first appears.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option one: keep the repo in one user’s home and link across.&lt;/strong&gt;&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;sh&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;cd&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ~/.config&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;ln&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -s&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; /Users/akc/.dotfiles/nvim/.config/nvim/&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; nvim&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;ln&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -s&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; /Users/akc/.dotfiles/lazygit/.config/lazygit/&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; lazygit&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This works, and it’s the smaller change if your dotfiles already live in &lt;code&gt;~&lt;/code&gt;. One caveat to
check on your own machine: macOS makes a home directory itself readable by other users but locks
down several subdirectories inside it. A &lt;code&gt;.dotfiles&lt;/code&gt; repository sitting directly in &lt;code&gt;~&lt;/code&gt; is
reachable; the same repo inside &lt;code&gt;~/Documents&lt;/code&gt; is not. If your symlinks resolve but the target
won’t open, that’s the reason, and moving the repo up a level fixes it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Option two: move the repo to &lt;code&gt;/Users/Shared&lt;/code&gt; and link from both accounts.&lt;/strong&gt;&lt;/p&gt;
&lt;pre style=&quot;color:#393a34;background-color:#ffffff;overflow-x:auto&quot; data-language=&quot;sh&quot;&gt;&lt;code&gt;&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;mv&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ~/.dotfiles&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; /Users/Shared/dotfiles&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#998418&quot;&gt;cd&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; ~/.config&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;ln&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -s&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; /Users/Shared/dotfiles/nvim/.config/nvim/&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; nvim&lt;/span&gt;&lt;/span&gt;
&lt;span class=&quot;line&quot;&gt;&lt;span style=&quot;color:#59873A&quot;&gt;ln&lt;/span&gt;&lt;span style=&quot;color:#A65E2B&quot;&gt; -s&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; /Users/Shared/dotfiles/lazygit/.config/lazygit/&lt;/span&gt;&lt;span style=&quot;color:#B56959&quot;&gt; lazygit&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;/Users/Shared&lt;/code&gt; exists on every Mac for exactly this: a directory outside anyone’s home that all
local accounts can read and write. This is what I ran for a long time, and it’s the better
default.&lt;/p&gt;
&lt;p&gt;Two reasons. The permissions problem disappears, because you’re no longer reaching into another
account’s home directory and hoping macOS lets you. And it removes the asymmetry. With option
one there’s a primary account that owns the config and a secondary one that borrows it, so
deleting or renaming that account takes your dotfiles with it. In &lt;code&gt;/Users/Shared&lt;/code&gt; neither
account is privileged, and both are just clients of the same repository.&lt;/p&gt;
&lt;p&gt;The tradeoff is that &lt;code&gt;/Users/Shared&lt;/code&gt; is writable by every local account, so anything with a
credential in it doesn’t belong there. Config yes, secrets no. That’s the rule anywhere, but
it’s easier to violate by accident when the directory is this convenient.&lt;/p&gt;
&lt;p&gt;Shell config is the exception. &lt;code&gt;.zshrc&lt;/code&gt;, &lt;code&gt;.zprofile&lt;/code&gt; and &lt;code&gt;.zshenv&lt;/code&gt; genuinely differ between the
accounts, with different aliases, different paths and different work-specific variables, so those get
copied once rather than linked, and then diverge on purpose.&lt;/p&gt;
&lt;h2 id=&quot;moving-files-between-your-own-two-accounts&quot;&gt;Moving files between your own two accounts&lt;/h2&gt;
&lt;p&gt;This is more annoying than it should be, because from the filesystem’s point of view you are
two unrelated people.&lt;/p&gt;
&lt;p&gt;Every macOS account has a &lt;code&gt;~/Public&lt;/code&gt; folder that is readable by other users on the machine,
with a &lt;code&gt;Drop Box&lt;/code&gt; inside it that is write-only from outside. That’s the intended channel, and
it’s what I use for the one-time transfers: shell config, zsh plugin directories
(&lt;code&gt;zsh-autosuggestions&lt;/code&gt;, &lt;code&gt;fast-syntax-highlighting&lt;/code&gt;), fonts.&lt;/p&gt;
&lt;p&gt;Fonts are worth a note of their own. Installing to &lt;code&gt;~/Library/Fonts&lt;/code&gt; makes them available to
one account only; &lt;code&gt;/Library/Fonts&lt;/code&gt; makes them available to everyone. If you’re using a patched
Nerd Font in your terminal, install it system-wide once and stop thinking about it.&lt;/p&gt;
&lt;p&gt;For iTerm2, don’t hand-recreate your setup. Export the profile as JSON from the original
account, drop it in &lt;code&gt;~/Public&lt;/code&gt;, and import it in the new one.&lt;/p&gt;
&lt;h2 id=&quot;what-the-setup-actually-looks-like&quot;&gt;What the setup actually looks like&lt;/h2&gt;
&lt;p&gt;After all of it, the division is:&lt;/p&gt;
&lt;div class=&quot;split-columns&quot;&gt;&lt;div class=&quot;split-columns__col&quot;&gt;&lt;p class=&quot;split-columns__title&quot;&gt;Shared&lt;/p&gt;&lt;ul class=&quot;split-columns__list&quot;&gt;&lt;li&gt;Homebrew and everything it installs&lt;/li&gt;&lt;li&gt;System fonts&lt;/li&gt;&lt;li&gt;Docker Desktop, the application&lt;/li&gt;&lt;li&gt;A dotfiles repo in /Users/Shared, symlinked into ~/.config from both&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;div class=&quot;split-columns__col&quot;&gt;&lt;p class=&quot;split-columns__title&quot;&gt;Separate&lt;/p&gt;&lt;ul class=&quot;split-columns__list&quot;&gt;&lt;li&gt;Shell configuration&lt;/li&gt;&lt;li&gt;SSH keys&lt;/li&gt;&lt;li&gt;Git identity&lt;/li&gt;&lt;li&gt;Keychains and browser profiles&lt;/li&gt;&lt;li&gt;Application state&lt;/li&gt;&lt;li&gt;Docker&amp;#39;s per-user CLI and contexts&lt;/li&gt;&lt;/ul&gt;&lt;/div&gt;&lt;/div&gt;
&lt;p&gt;Which is roughly the right line. The things that are expensive to duplicate are shared; the
things that carry identity or credentials are not.&lt;/p&gt;
&lt;h2 id=&quot;the-thing-i-keep-thinking-about&quot;&gt;The thing I keep thinking about&lt;/h2&gt;
&lt;p&gt;None of these are bugs. Homebrew’s single-prefix design is a deliberate trade that makes the
common case fast and simple, and macOS’s per-user isolation is correct. Docker Desktop putting
its CLI in the user’s home is defensible too.&lt;/p&gt;
&lt;p&gt;But every one of them encodes an assumption that a laptop belongs to one person, and that
assumption is doing a lot of quiet work. The moment you need two accounts, whether for a job, a
contract, or a boundary you want to keep, you’re not fighting a limitation so much as
discovering a premise nobody wrote down.&lt;/p&gt;
&lt;p&gt;There’s a version of this in most software I work on. The default configuration is the shape of
someone’s assumption about who you are, and you only find out what that assumption was when you
stop matching it.&lt;/p&gt;</content:encoded><category>Writing</category><category>macos</category><category>homebrew</category><category>docker</category><category>dotfiles</category><category>setup</category></item></channel></rss>