<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="/rss/styles.xsl" type="text/xsl"?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Conner&#39;s Corner</title>
<description>A personal website</description>
<link>https://connermccall.com/</link>
<atom:link href="https://connermccall.com/rss.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>

<item>
    <title>A homescreen from 14 years ago</title>
    <link>https://connermccall.com/blog/2026/07/09/a-screen-from-the-past/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/07/09/a-screen-from-the-past/</guid>
    <description>Throwback to glossy icons</description>
    <pubDate>Thu, 09 Jul 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;Oh boy, here is a blast from the past. I was looking at my memories from &lt;a href=&#34;https://immich.app/&#34; target=&#34;_blank&#34;&gt;Immich&lt;/a&gt;. Immich takes a simple approach, it surfaces  photos you took on this date and month in past years, without curation. I like this approach over the curated experiences Google Photos tended to create. I do not need 12 photos of my dead cat on a random Tuesday, but maybe 3 I took 7 years ago would be fine.&lt;/p&gt;

&lt;p&gt;Anyway, today there was a screenshot in my photos from 14 years ago. It took me down memory lane, so I thought I&amp;rsquo;d share.&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2026/07/images/2012-07-09.png&#34; alt=&#34;iPhone homescreen screenshot&#34; /&gt;&lt;/p&gt;

&lt;p&gt;Some thoughts on the various rows.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Row 1&lt;/strong&gt; &lt;em&gt;(Phone, Messages, Camera, Fahrenheit)&lt;/em&gt; Defaults, except for weather. I have no recollection of this app, but I am pretty sure the temperature was a hack they pulled off by using the notification badge to display the temperature.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row 2&lt;/strong&gt; &lt;em&gt;(Settings, Clock, Todo.txt, Maps)&lt;/em&gt; Fairly boring. I never got into &lt;a href=&#34;http://todotxt.org/&#34; target=&#34;_blank&#34;&gt;todo.txt&lt;/a&gt; but it is a great idea. My maps app is in the same basic spot on my current phone. Muscle memory for the win.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row 3&lt;/strong&gt; &lt;em&gt;(Instagram, Facebook, Entertainment, Safari)&lt;/em&gt; The only interesting thing on this row is the Entertainment folder.

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Rdio&lt;/em&gt; - Spotify Competitor that was bought by Pandora. It was much nicer than Spotify back then.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;MPoD&lt;/em&gt; - A &lt;a href=&#34;https://www.musicpd.org/&#34; target=&#34;_blank&#34;&gt;music player daemon&lt;/a&gt; controller. I had a decently fun streaming setup in my apartment&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Pandora&lt;/em&gt; - Still around, but I maybe use it 1 time a year&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Itunes&lt;/em&gt; - default&lt;/li&gt;
&lt;li&gt;&lt;em&gt;This American Life&lt;/em&gt; - They had to distribute an app for their podcast. I miss those days.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Photos&lt;/em&gt; - useful but default&lt;/li&gt;
&lt;li&gt;&lt;em&gt;iMovie&lt;/em&gt; - did anyone actually use this?&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Roku&lt;/em&gt; - Still use this, though with their &lt;a href=&#34;https://newsroom.roku.com/news/2026/06/fox-corporation-to-acquire-roku-inc-/ishjvytb-1781489389&#34; target=&#34;_blank&#34;&gt;impending sale to Fox&lt;/a&gt; I am searching out alternatives&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Kindle&lt;/strong&gt; - Left this ecosystem several years ago, also I never enjoyed reading a book on my phone.&lt;/li&gt;
&lt;/ul&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row 4&lt;/strong&gt; &lt;em&gt;(Reminders, foursquare)&lt;/em&gt; - I am sure that reminders app was used sparingly. Foursquare though, I loved that social experience. Paired with a Twitter that could still feel like a small town, it made going out for drinks to win back your mayor badge something you probably did too often.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Home Row&lt;/strong&gt; - You know these were the most important apps.

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;1Password&lt;/em&gt; - Still use it, still recommend it.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Mail icon&lt;/em&gt; - I have no idea what mail app this was.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Google Voice&lt;/em&gt; - I still cannot believe we used to have to listen to voicemails. I still keep my Google Voice account just for the transcription feature.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Fantastical&lt;/em&gt; (I think) - I think the 8 means this photo is actually dated incorrectly, and was captured the 8th instead of the 9th. Oh well. That badge was the same hack as the weather app used.&lt;/li&gt;
&lt;li&gt;&lt;em&gt;TweetBot&lt;/em&gt; - 3rd party clients and Twitter were conversation starters in those days.&lt;/li&gt;
&lt;/ul&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Today I have an Android device, and looking through my photos it looks like I switched in February of 2014. If you are curious about what some of my daily apps are now, you can find my &lt;a href=&#34;/defaults&#34;&gt;defaults here&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>How This Blog is Deployed</title>
    <link>https://connermccall.com/blog/2026/07/06/deployment/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/07/06/deployment/</guid>
    <description>A short walkthrough of my ci/cd process</description>
    <pubDate>Mon, 06 Jul 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I mentioned &lt;a href=&#34;/blog/2026/06/23/blog-is-a-binary/&#34;&gt;last month&lt;/a&gt; I had changed the underlying tech driving this blog to be primary written in &lt;a href=&#34;https://go.dev/&#34; target=&#34;_blank&#34;&gt;golang&lt;/a&gt;. I may release the structure of that application at some point, but for now I thought I would document how I deploy this service to my VPS.&lt;/p&gt;

&lt;p&gt;My VPS is hosted on hetzner, I try to host most of my external-facing things there to avoid my in home NAS from being too much of an attack surface. My previous deployment process was faily simple but not pipeline-driven.&lt;/p&gt;

&lt;h2 id=&#34;previous-deploys&#34;&gt;Previous Deploys&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write new content or add a new feature&lt;/li&gt;
&lt;li&gt;run &lt;code&gt;make build&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;run &lt;code&gt;make sync&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This did a &lt;code&gt;docker build&lt;/code&gt; which exported the contents of the static site generator&amp;rsquo;s output to a local folder, then &lt;code&gt;make sync&lt;/code&gt; ran &lt;code&gt;rsync&lt;/code&gt; to push the changes to a folder on my vps. My site at that time was a nginx service sitting behind my nginx proxy.&lt;/p&gt;

&lt;p&gt;I wanted to keep a similiar process with the rewrite, but I wanted it to be backed by a pipeline, so I could write anywhere with access to my git server, and deploy with a push. I also wanted this to be much more secure and locked down than my previous action, which involved my primary user copying files to the service. This user had full sudo access to the machine, which is not ideal security practice.&lt;/p&gt;

&lt;p&gt;After some research and experimentation, here is where things stand.&lt;/p&gt;

&lt;h2 id=&#34;server-user-setup&#34;&gt;Server User Setup&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I have a user &lt;code&gt;deploy&lt;/code&gt; on my VPS. My VPS is named &lt;code&gt;ext&lt;/code&gt;(yes this is a departure from my &lt;a href=&#34;/blog/2025/04/07/a-naming-system/&#34;&gt;naming system&lt;/a&gt;). This user has a home directory in my service folder.&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code&gt;sudo useradd -M -d /var/docker-service/blog/ -s /usr/sbin/nologin deploy
sudo chown -R deploy:deploy /var/docker-service/blog/
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Then I created a wrapper script named &lt;code&gt;/usr/local/bin/blog-compose&lt;/code&gt; for running docker-compose commands in that directory. `&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code&gt;#!/bin/bash
set -e
cd /var/docker-service/blog
exec docker compose -f /var/docker-service/blog/docker-
compose.yml &amp;quot;$@&amp;quot;
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Make this script executable&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code&gt;sudo chmod +x /usr/local/bin/blog-compose
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Now you need to allow that script to be run with sudo as your user. This allows this user to perform docker commands without needing to be a member of the docker group&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code&gt;sudo visudo -f /etc/sudoers.d/blog
### contents
deploy ALL=(root) NOPASSWD: /usr/local/bin/blog-compose
deploy ALL=(root) NOPASSWD: /usr/local/bin/blog-compose *
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;Finally, you need to generate an ssh key and add it to &lt;code&gt;/var/docker-service/blog/.ssh/authorized_key&lt;/code&gt; file. I added a passphrase for additional security. There are 1 million articles on this on the web, so I won&amp;rsquo;t repeat those details.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2 id=&#34;docker-compose&#34;&gt;Docker Compose&lt;/h2&gt;

&lt;p&gt;I run all my services as docker containers. This is partly for encapsulation and partly because it makes adding and removing services simple. I front the containers with &lt;a href=&#34;https://github.com/nginx-proxy/nginx-proxy&#34; target=&#34;_blank&#34;&gt;nginx-proxy&lt;/a&gt; and it&amp;rsquo;s &lt;a href=&#34;https://github.com/nginx-proxy/acme-companion&#34; target=&#34;_blank&#34;&gt;acme-companion&lt;/a&gt;, which makes SSL super low maintenance. Because of this, the site is run as a Docker container. Here is the corresponding compose file.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Notes:&lt;/em&gt; I use &lt;code&gt;scratch&lt;/code&gt; as my base image for running the site, this makes for a smaller image and reduces potential attack surfaces. Because of this I am mounting the root SSH keys and timezone data into the container. Without these, requests to my mealie instance fail as it cannot validate the ssh key and using a timezone to gatekeep post publishing fails as it cannot find timezone data.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;services:
  connermccall.com:
# I run a docker registry internally for personal projects
    image: registry.connermccall.me/personal/connermccall.com:latest
    environment:
      - &amp;quot;URL=connermccall.com&amp;quot;
# Recipe retrieval requires a key
      - &amp;quot;MEALIE_API_KEY=${MEALIE_API_KEY}&amp;quot;
# there are the important things, this tell the proxy and companion what domains we are serving this site on. 
      - &amp;quot;LETSENCRYPT_EMAIL=conner@connermccall.com&amp;quot;
      - &amp;quot;LETSENCRYPT_HOST=connermccall.com,www.connermccall.com&amp;quot;
      - &amp;quot;VIRTUAL_HOST=connermccall.com,www.connermccall.com&amp;quot;
      - &amp;quot;VIRTUAL_PORT=8080&amp;quot;
    networks:
      - network-nginx-proxy
    restart: &amp;quot;unless-stopped&amp;quot;
    volumes:
      - &amp;quot;/etc/localtime:/etc/localtime:ro&amp;quot;
      - &amp;quot;/etc/ssl/certs/ca-certificates.crt:/etc/ssl/certs/ca-certificates.crt:ro&amp;quot;
      - &amp;quot;/usr/share/zoneinfo:/usr/share/zoneinfo:ro&amp;quot;
    logging:
      driver: syslog
      options:
        tag: &amp;quot;docker/connermccall-com&amp;quot;
# I mark this as external as it is created with tofu elsewhere. The container has to be on this network for the proxy and companion to recognize it
networks:
  network-nginx-proxy:
    external: true
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&#34;ci-setup&#34;&gt;CI Setup&lt;/h2&gt;

&lt;p&gt;I use Forgejo as my forge. It has been rock solid for the past several years and I have a runner configured on my local NAS. This gives me more compute than my VPS provides along with plenty of disk space and access to local resources.&lt;/p&gt;

&lt;p&gt;I will walk through the pertinent parts of my script, the full version is at the end of this post.&lt;/p&gt;

&lt;p&gt;The first five steps are checking out the container, setting up build details, and actually building and pushing the container. Once that is done we need to update the running container on my VPS, which we do through SSH.&lt;/p&gt;

&lt;p&gt;I chose to protect my ssh key with a passphrase. This passphrase is stored in 1Password. They provide a github action for fetching secrets. This takes a Service Token and then a map of keys to values. In this case we map the &lt;code&gt;password&lt;/code&gt; field from the secret named &lt;code&gt;ssh_passphrase&lt;/code&gt; in the &lt;code&gt;forgejo&lt;/code&gt; vault to the output &lt;code&gt;SSH_KEY_PASSPHRASE&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Fetch Passphrase
  id: fetch_passphrase
  uses: https://github.com/1password/load-secrets-action@v4
  env:
    OP_SERVICE_ACCOUNT_TOKEN: ${{ env.OP_SERVICE_ACCOUNT_TOKEN }}
    SSH_KEY_PASSPHRASE: op://forgejo/ssh_passphrase/password
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Once we have the passphrase we need to update the image and recreate the container on my vps. I used an ssh action to accomplish this. We need to pass the ssh key, the passphrase, the hostname, user, and ideally the host fingerprint to the command.  Finally the command must be run with &lt;code&gt;sudo&lt;/code&gt; so that the user has access to the necessary permissions for interacting with docker.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;- name: Update compose
  uses: https://github.com/appleboy/ssh-action@v1.2.5
  with:
     username: ${{ vars.USER }}
     host: ${{ secrets.HOST }}
     key: ${{ secrets.SSH_KEY }}
     passphrase: ${{ steps.fetch_passphrase.outputs.SSH_KEY_PASSPHRASE }}
     fingerprint: ${{ secrets.HOST_FINGERPRINT}}
     script: |
        sudo /usr/local/bin/blog-compose up -d --pull always --force-recreate
&lt;/code&gt;&lt;/pre&gt;

&lt;h2 id=&#34;gotchas&#34;&gt;Gotchas&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Ensure you set the permissions correctly on the .ssh directory and authorized key file.&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code&gt;ext:~$ chmod 700 .ssh/
ext:~$ chmod 600 .ssh/authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;I technically named my user something different which included a dot in their username. This tripped up sudo when I added the file user &lt;code&gt;visudoers.d&lt;/code&gt; with the actual username. &lt;code&gt;sudo&lt;/code&gt; ignores files with dots in their name. The easy fix was renaming the file with a &lt;code&gt;-&lt;/code&gt; instead of a dot. But to confirm that you successfully granted the user permission you can run.&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code&gt;sudo -l -U deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;You may need to try a couple different host keys to figure out which one will work. I initially used the &lt;code&gt;ssh_host_ecdsa_key&lt;/code&gt; fingerprint and the pipeline failed. I settled on the &lt;code&gt;ssh_host_ed25519_key&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code&gt;ext:~$ ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
256 SHA256:XXXX....XX root@ext (ED25519)
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;You need the 2nd string, including the SHA256 part. &lt;code&gt;SHA256:XXXX....XX&lt;/code&gt;&lt;/p&gt;

&lt;h2 id=&#34;full-pipeline&#34;&gt;Full Pipeline&lt;/h2&gt;

&lt;pre&gt;&lt;code&gt;name: Build and Deploy New Version

env:
  IMAGE_NAME: connermccall.com
  REPO: registry.connermccall.me/personal


on:
  push:
    branches:
      - &#39;main&#39;

jobs:
  docker-build:
    name: Build and push docker image
    runs-on: docker
    container:
      image: catthehacker/ubuntu:act-latest
    steps: 
      - name: Checkout
        uses: actions/checkout@v4
      - name: Docker meta
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.REPO }}/${{ env.IMAGE_NAME }}
          flavor: latest=auto
          tags: |
            type=ref,event=branch
            type=sha
            type=raw,value=latest,enable={{is_default_branch}}
      - name: Resolve version info
        id: version
        run: |
          echo &amp;quot;GIT_HASH=$(git rev-parse --short HEAD)&amp;quot; &amp;gt;&amp;gt; $GITHUB_ENV
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          push: true
          context: .
          cache-from: type=registry,ref=${{ env.REPO }}/${{ env.IMAGE_NAME }}:latest
          cache-to: type=inline
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            ASSET_HASH=${{ env.GIT_HASH }}
      - name: Fetch Passphrase
        id: fetch_passphrase
        uses: https://github.com/1password/load-secrets-action@v4
        env:
          OP_SERVICE_ACCOUNT_TOKEN: ${{ env.OP_SERVICE_ACCOUNT_TOKEN }}
          SSH_KEY_PASSPHRASE: op://forgejo/ssh_passphrase/password
      - name: Print masked secret
        run: &#39;echo &amp;quot;Secret: ${{ steps.fetch_passphrase.outputs.SSH_KEY_PASSPHRASE }}&amp;quot;&#39;
      - name: Update compose
        uses: https://github.com/appleboy/ssh-action@v1.2.5
        with:
          username: ${{ vars.USER }}
          host: ${{ secrets.HOST }}
          key: ${{ secrets.SSH_KEY }}
          passphrase: ${{ steps.fetch_passphrase.outputs.SSH_KEY_PASSPHRASE }}
          fingerprint: ${{ secrets.HOST_FINGERPRINT}}
          script: |
           sudo /usr/local/bin/blog-compose up -d --pull always --force-recreate
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item>

<item>
    <title>Comparing Word Docs</title>
    <link>https://connermccall.com/blog/2026/07/04/compare-word-docs/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/07/04/compare-word-docs/</guid>
    <description>Another way to check word docs </description>
    <pubDate>Sat, 04 Jul 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I have a client who sends me Word documents quite frequently. They are almost always a revision of a previous document. The document is very long and can be challenging to parse. For many years I would just manualy look for what changed, or use the Compare feature in LibreOffice. This never worked well, as it was based on text movement and formatting which caused a lot of changes to be irrelevant. So I mostly just read the entire thing to ensure no changes impacted features I was building.&lt;/p&gt;

&lt;p&gt;This week I got a new version and for some reason I thought to myself, can I compare this with &lt;code&gt;diff&lt;/code&gt;. It turns out we can.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;pandoc -f docx -t old-document.docx -o old-document.md
pandoc -f docx -t new-document.docx -o new-document.md
diff old-document.md new-document.md
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The pandoc conversion is excellent and since you end up with a plain txt file, diff does what it does best. I was able to quickly review what had changed and provide feedback without needing to read the entire 8 page document. It is kind of a weird scenario, but hopefully I help one or two people who are also stuck in a document Groundhog&amp;rsquo;s Day loop.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>ESPHome Controlled Fireplace Blower</title>
    <link>https://connermccall.com/blog/2026/07/01/fireplace-fan/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/07/01/fireplace-fan/</guid>
    <description>Building a quiet and controllable fireplace blower</description>
    <pubDate>Wed, 01 Jul 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;Our home had a gas fireplace in our living room when we moved in. Though the radiant heat from it felt good when winter came, there was no blower installed so a lot of the heat was wasted. After one winter of wasted heat, I purchased a blower designed for gas fireplaces. It gets inserted into the base of the unit and turns on based on the temperature. The fan pulls air from under the unit, which is exposed to the room, and blows it through a vent in the back of the unit which heats the air and ejects it through the vent at the top.&lt;/p&gt;

&lt;p&gt;This worked great for years, but this winter I was being driven insane by the noise. The fan was loud enough to require cranking up the volume when watching TV. The only way to stop it was to unplug it and we would inevitably forget to plug it back in.&lt;/p&gt;

&lt;p&gt;I considered a few options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Just use a smart plug and be able to control it if needed, with automatic on overnight.&lt;/li&gt;
&lt;li&gt;Buy a new, more expensive fan and hope it would be quieter&lt;/li&gt;
&lt;li&gt;Do it myself.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I like do it myself as an option, so I dug into the possibilities. I was sure I could use ESPHome to accomplish my goals and after some research I learned that yes, ESPHome could handle this problem. I listed these as my goals.&lt;/p&gt;

&lt;h3 id=&#34;goals&#34;&gt;Goals&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[ x ] - Quiet operation&lt;/li&gt;
&lt;li&gt;[ x ] - Able to control the fan from Home Assistant&lt;/li&gt;
&lt;li&gt;[ x ] - Use standard components, so I can fix it if something fails&lt;/li&gt;
&lt;li&gt;[ x ] - Monitor Temperature from Home Assistant&lt;/li&gt;
&lt;li&gt;[ x ] - Original remote must still work(spousal approval)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&#34;stretch-goals&#34;&gt;Stretch Goals&lt;/h3&gt;

&lt;p&gt;I done some preliminary research on how to achieve these, but have yet to spend a ton of time working on them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] - Control Fireplace burner state from Home Assistant&lt;/li&gt;
&lt;li&gt;[ ] - Determine Fireplace burner state in Home Assistant&lt;/li&gt;
&lt;li&gt;[ ] - Climate Control&lt;/li&gt;
&lt;/ul&gt;

&lt;h3 id=&#34;build-details&#34;&gt;Build Details&lt;/h3&gt;

&lt;p&gt;I ended up with the following components:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Three 120mm PWM fans&lt;/li&gt;
&lt;li&gt;esp32&lt;/li&gt;
&lt;li&gt;DHT11 Temperature and Humidity Sensor&lt;/li&gt;
&lt;li&gt;12v Buck Converter&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2026/06/images/fireplace_blower_bb.jpg&#34; alt=&#34;Fireplace Blower Fritzing Diagram&#34; /&gt;&lt;/p&gt;

&lt;p&gt;The fans are wired in series, 12v from the DC transformer, PWM signals, and ground are all wired together.&lt;/p&gt;

&lt;p&gt;The buck converter receives 12v from the transformer, drops the voltage and sends 5v to the dht11, and the vin pin on the esp32. Ground from both the dht11 and esp32 is wired to output ground on the buck converter, along with the ground from the fans.&lt;/p&gt;

&lt;p&gt;PWM from the fans is wired to a PWM output pin on the esp32 and the DHT11 signal pin is wired into a GPIO.&lt;/p&gt;

&lt;p&gt;Once I validated everything worked well, I soldered things together and stuck them into a plastic case. The fans were mounted to a 3d printed case that allows air to flow from underneath and push up through the vents. I also stuck a case around the DHT11 and attached it to the top of the lower compartment with a magnet, which improves temperature pickup.&lt;/p&gt;

&lt;h3 id=&#34;software-notes&#34;&gt;Software Notes&lt;/h3&gt;

&lt;p&gt;The important bits of my esphome configuration file are at the bottom of this post.&lt;/p&gt;

&lt;p&gt;My primary goal was to ensure this would continue to function even if Home Assistant or the network went down. Home Assistant&amp;rsquo;s only role is as a dashboard and allowing for setting thresholds remotely. The actual decisions to turn the fan on, speed, and turning it off is fully handled by the esp32 device.&lt;/p&gt;

&lt;p&gt;I am extremely happy with the result. The fans are significantly quieter when running at lower speeds and if I bump their speed up a bit beyond silent they move a ton more air than the purpose build blower did.&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2026/06/images/fireplace_dashboard.png&#34; alt=&#34;Image of Home Assistant Dashboard&#34; /&gt;&lt;/p&gt;

&lt;hr&gt;

&lt;pre&gt;&lt;code&gt;# Allows setting thresholds within Home Assistant but storing them on the device. 
number:
  - platform: template
    name: &amp;quot;$friendly_name Low Temp Threshold&amp;quot;
    id: low_temp_threshold
    optimistic: true
    min_value: 50
    max_value: 80
    step: 1
    initial_value: 70
    unit_of_measurement: &amp;quot;°F&amp;quot;
    mode: box
    restore_value: true

  - platform: template
    name: &amp;quot;$friendly_name High Temp Threshold&amp;quot;
    id: high_temp_threshold
    optimistic: true
    min_value: 80
    max_value: 120
    step: 1
    initial_value: 98
    unit_of_measurement: &amp;quot;°F&amp;quot;
    mode: box
    restore_value: true

  - platform: template
    name: &amp;quot;$friendly_name Max Fan Speed&amp;quot;
    id: max_fan_speed
    optimistic: true
    min_value: 13
    max_value: 100
    step: 1
    initial_value: 65
    unit_of_measurement: &amp;quot;%&amp;quot;
    mode: slider
    restore_value: true

# the lamda here does most of the work. It triggers the fans on based on the temperature thresholds above and ramps up the fan
# as the temperature increases. 
sensor:
  - platform: dht
    pin: GPIO21
    model: DHT11
    temperature:
      id: &amp;quot;fireplace_temperature&amp;quot;
      name: &amp;quot;$friendly_name Temperature&amp;quot;
      accuracy_decimals: 1
      filters:
         - exponential_moving_average:
             alpha: 0.1
             send_every: 1
         - lambda: return x * (9.0/5.0) + 32.0;
      unit_of_measurement: &amp;quot;°F&amp;quot;
      on_value:
        then:
          - lambda: |-
              float temp = id(fireplace_temperature).state;
              float low_temp = id(low_temp_threshold).state;
              float high_temp = id(high_temp_threshold).state;
              float max_speed = id(max_fan_speed).state;
              bool manual_mode = id(manual_control).state;

              if (temp &amp;lt;= low_temp) {
                // Below low threshold - turn off
                id(fireplace_fan).turn_off().perform();
              } else if (manual_mode) {
                // Manual mode is on - do nothing, let user control
                return;
              } else if (temp &amp;gt;= high_temp * 0.95) {
                // At or above 95% of high temp - max speed
                id(fireplace_fan).turn_on().set_speed(max_speed).perform();
              } else {
                // Between low and 95% of high - linear ramp
                float temp_range = (high_temp * 0.95) - low_temp;
                float temp_position = temp - low_temp;
                float speed_percent = (temp_position / temp_range) * max_speed;
                speed_percent = max(1.0f, min(max_speed, speed_percent));

                id(fireplace_fan).turn_on().set_speed((int)speed_percent).perform();
              }
    
# Some fun wiring up of output and fan to allow fan monitoring in home assistant
output:
  - platform: ledc
    pin: GPIO25
    id: fireplace_fan_pwm
    frequency: 25000 Hz  # Above audible range
    min_power: 13%
    zero_means_zero: true

fan:
  - platform: speed
    output: fireplace_fan_pwm
    id: fireplace_fan
    name: &amp;quot;$friendly_name Fan&amp;quot;
    speed_count: 100  # Gives you 0-100% control

# Allows full manual control of the fan.   
switch:
  - platform: template
    name: &amp;quot;$friendly_name Manual Control&amp;quot;
    id: manual_control
    optimistic: true
    restore_mode: RESTORE_DEFAULT_OFF
&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

&lt;p&gt;&lt;em&gt;Note:&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I ran into a really frustrating issue throughout this project where Home Assistant would stop communicating with the device. I could get data from the device to HA, but not consistently send updates. I managed to track it down and wrote a &lt;a href=&#34;/blog/2026/01/28/docker-vlan-netmask/&#34;&gt;post mortem&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>The Wizard is Naz More</title>
    <link>https://connermccall.com/blog/2026/06/27/wizard-of-naz/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/06/27/wizard-of-naz/</guid>
    <description>An ode to a Minnesota Folk Hero</description>
    <pubDate>Sat, 27 Jun 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;Thursday I saw the headline &amp;ldquo;Wolves Trade Naz Reid to Hornets for LaMelo Ball&amp;rdquo;. I frantically clicked the link and yes, that was real,
the Wolves had done something I thought impossible, they traded their most loved big man away for a player who looks extremely flawed but could be a potential star.&lt;/p&gt;

&lt;p&gt;My thoughts on the trade aside, Nas Reid was such a fixture in Minnesota. From the moment he started getting consistent minutes, until he played through a damaged shoulder in this year&amp;rsquo;s playoffs, he was beloved. I had many moments of screaming &amp;ldquo;NAZ&amp;rdquo; in excitement and &amp;ldquo;NAZ&amp;rdquo; in frustration. Other people went further, getting &lt;a href=&#34;https://www.mprnews.org/story/2024/05/10/minnesota-timberwolves-naz-reid-tattoos-roseville&#34; target=&#34;_blank&#34;&gt;tattoos&lt;/a&gt; of his name on their bodies. His &lt;a href=&#34;https://www.the-sun.com/sport/11022043/naz-reid-towel-minnesota-timberwolves-wrestlemania-wwe-nba/&#34; target=&#34;_blank&#34;&gt;beach towel&lt;/a&gt; became a symbol of unity and hearing &lt;a href=&#34;https://x.com/Grady/status/2070151043103547809#m&#34; target=&#34;_blank&#34;&gt;Michael Grady&lt;/a&gt; say &amp;ldquo;Two Words&amp;rdquo; was magnificent when Reid started splashing threes from all over the floor.&lt;/p&gt;

&lt;p&gt;During the 2025 Playoff run, we got a Bark Box that came with a little wizard. It was an immediate smash hit. Fin loved that thing from day one, and even today it is in his rotation. The night before Reid got traded, the Wizard was laying somewhere in the house, and while we were watching TV all of a sudden you heard galloping and here comes the dog with the wizard in his mouth, ready to party. It coincided perfectly with the newish nickname fans had bestowed on Nas, &lt;a href=&#34;https://www.youtube.com/watch?v=OSEqx8VznCY&#34; target=&#34;_blank&#34;&gt;The Wizard of Naz&lt;/a&gt;. Game days for that series always meant we got out the wizard, who not only remained a hit toy, but also somehow avoided being torn to shreds, a fate most toys face.&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2026/06/images/wizard-of-naz.webp&#34; alt=&#34;Image of Wizard Dog Toy&#34; /&gt;
Maybe Fin sensed the phone calls the Wolves were making over in Minneapolis, but today at lunch I looked down and I think the Wizard looked sad. I think a lot of Wolves fans are feeling the same, Nas was a symbol of hard work and dedication and we took pride in him being one of us. He will be missed and I think we all wish him the best in Charlotte.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>I build a Website for my Home</title>
    <link>https://connermccall.com/blog/2026/06/25/home-website/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/06/25/home-website/</guid>
    <description>My house now has a public Internet Presence</description>
    <pubDate>Thu, 25 Jun 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;We have lived in our home for five years now. I have done a lot of stuff in it, both physical improvements and digital additions. My house is on the edge of being too smart, which is exactly how I like it.&lt;/p&gt;

&lt;p&gt;One thing I love to do is garden. Initially this just meant growing edible plants in my garden beds, but as time has passed I have started to push outside the beds and expand my garden to encompass more of my yard. Doing this though required paying more attention to the species of plants that were already there and I planted.&lt;/p&gt;

&lt;p&gt;Garden journaling has always failed me. Digital, analog, it did not matter. I would start all excited documenting my plans and maybe even the first planting, then nothing. Come October when we started cleaning out I had no notes on what went well or what went poorly.&lt;/p&gt;

&lt;p&gt;I had always wanted a little site for my home that someone could visit to learn about it and realized this could solve my note taking problem. At the time I had gotten more intrigued by Go. So I started writing. I intentionally avoided LLMs and just tried to figure out as much as possible through documentation. And I ended up with a pretty solid piece of software.&lt;/p&gt;

&lt;p&gt;My goal with this website is for it to be discoverable by passers-by. Currently it has a little homepage with some details about the house, the current temperature and humidity, and the weekly rainfall(quite a bit this week).&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2026/06/images/stewie-homepage.png&#34; alt=&#34;Homepage&#34; /&gt;&lt;/p&gt;

&lt;p&gt;But the real fun is my plant database. I started adding labels with QR codes to my plants as I identified or planted them. Now anyone passing by or in my yard can scan this QR code and get a page showing information about that plant and its species.&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2026/06/images/oregano.png&#34; alt=&#34;Oregano Overview&#34; /&gt;&lt;/p&gt;

&lt;p&gt;Each QR code leads to a stake id, which is tied to a plant. My current stakes are temporary, so making new ones for new plants is not a big deal, but ideally I would figure out a way to make them permanent, maybe by etching them directly onto the stakes. Then I can reassign the stake to a new plant whenever I want.&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2026/06/images/choc-cherry-label.jpg&#34; alt=&#34;Sunflower with QR Label&#34; /&gt;&lt;/p&gt;

&lt;p&gt;Along with the public facing page, each plant also has more data on the admin side. Particularly notes. I can add notes, and additional photos very quickly. I even setup a custom redirect on my phone&amp;rsquo;s browser, which redirects from the public page to the admin page. So adding a new photo or note just requires a quick scan of the QR code, then I can add what I want. This has made keeping a better running log of a plant&amp;rsquo;s progress a lot more natural.&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2026/06/images/strawberry.png&#34; alt=&#34;Strawberry Admin&#34; /&gt;&lt;/p&gt;

&lt;p&gt;I have a few features I want to add and having this starting point will lead to more ideas. Currently I want to add a QR code to my in progress Little Free Library where people can leave notes for other readers (might be a terrible idea), expose more climate information by adding a &lt;a href=&#34;https://weewx.com&#34; target=&#34;_blank&#34;&gt;weewx&lt;/a&gt; dashboard, and a log of projects we have done or are doing that are visible from outside.&lt;/p&gt;

&lt;p&gt;It has been a fun addition to my web portfolio, something small, meaningful, and maybe something that will drive connections with neighbors.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>This Site is an Executable Binary</title>
    <link>https://connermccall.com/blog/2026/06/23/blog-is-a-binary/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/06/23/blog-is-a-binary/</guid>
    <description>I rewrote the architecture with Go</description>
    <pubDate>Tue, 23 Jun 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I have a lot of excuses for not writing more, but recently I had a whole list of ideas of things to write and I got caught up in the problem of publishing too many things at once.&lt;/p&gt;

&lt;p&gt;This is mostly just a lame excuse, but it led me down a path of trying to find a way to automate post publishing so I could write a bunch of content at the same time and have it slowly trickle out.&lt;/p&gt;

&lt;p&gt;My site has run as a static site built with Astro for 3 years. This served me well, except for the fact that publishing a new post was a manual act. Whether pushing to a git repo to trigger a build and deploy or manually building and pushing, in all cases, I could not deploy any content I wanted to show at a later date to my production server.&lt;/p&gt;

&lt;p&gt;My initial plan was to setup a workflow in Forgejo that would run on a schedule, check for branches matching a date pattern, if the date was today or earlier, merge it back and deploy the built site. This got complicated and seemed failure prone. I would have had to build at least two new workflows and also remember to name my branches with a date with no validation of the name before the workflow ran. This is not what git does well. So I reached for a tool I have been spending time learning. Go!&lt;/p&gt;

&lt;p&gt;I managed to replicate almost everything from Astro in just a couple of days. I can still write in Markdown, have Front Matter. I still have recipes available from &lt;a href=&#34;https://connermccall.com/blog/2024/03/25/mealie-for-recipes/&#34; target=&#34;_blank&#34;&gt;Mealie&lt;/a&gt;. The one missing piece is publishing &lt;a href=&#34;https://connermccall.com/replying/&#34; target=&#34;_blank&#34;&gt;Webmentions&lt;/a&gt;. As I have never gotten a mention I will add this if it is useful. The best part is now everything is contained in a single go binary with significantly less dependencies than Astro.&lt;/p&gt;

&lt;p&gt;I am using Go&amp;rsquo;s &lt;a href=&#34;https://pkg.go.dev/embed&#34; target=&#34;_blank&#34;&gt;embed&lt;/a&gt; to include all content, images, and templates into a single file which can be run with a simple &lt;code&gt;~/ &amp;gt; ./connermccall-com&lt;/code&gt;.  Go&amp;rsquo;s build system also means that it is mostly trivial to build a version for different platforms. So if I provision an ARM machine one day, I can run it there.&lt;/p&gt;

&lt;p&gt;This was a fun endeavor and great exercise to push my language knowledge. I really like the fact that I could write, commit, and build the site on Tuesday and have this post go live on Thursday without any intervention at publish time&lt;sup&gt;1&lt;/sup&gt;.&lt;/p&gt;

&lt;p&gt;&lt;sup&gt;1&lt;/sup&gt; Technically this should be date, since currently I just use date strings.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Docker VLAN Netmasks</title>
    <link>https://connermccall.com/blog/2026/01/28/docker-vlan-netmask/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/01/28/docker-vlan-netmask/</guid>
    <description>Keep your network subnets in sync</description>
    <pubDate>Wed, 28 Jan 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I wrote previously about &lt;a href=&#34;/blog/2025/12/22/docker-vlans&#34;&gt;using ipvlans&lt;/a&gt; in Docker. For the first few months everything was groovy, but I ran into a surprising and irritating issue I thought I should write about.&lt;/p&gt;

&lt;p&gt;About a month ago I changed my network to expand the IP address pool. Going from 10.49.1.0/24 to 10.49.0.0/22, which added addresses from 10.49.0.x, 10.49.2.x, and 10.49.3.x. I had reasons and things just worked for the most part.&lt;/p&gt;

&lt;p&gt;I was working on a new ESPHome project a little bit later and was running into a very annoying bug. While the ESPHome node was happily sending updates to Home Assistant, Home Assistant would stop being able to send data to the node after a short time window. I would see the message in the logs &lt;code&gt;WARNING Home Assistant 2025.10.2 (10.49.1.98): is unresponsive; disconnecting&lt;/code&gt; followed by &lt;code&gt;INFO Successfully connected&lt;/code&gt;. I would be able to connect for about 10-30 seconds, then poof, connection gone.&lt;/p&gt;

&lt;p&gt;I went down a ton of rabbit holes with ESPHome, and &lt;a href=&#34;https://community.home-assistant.io/t/home-assistant-api-disconnects-from-esphome-when-using-expanded-subnet-range/979900&#34; target=&#34;_blank&#34;&gt;opened an topic&lt;/a&gt; on their forum. The culprit turned out to be a Docker configuration issue on my end.&lt;/p&gt;

&lt;p&gt;When I updated my subnet, I failed to update the Docker vlan subnet. My initial configuration looked like.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;resource &amp;quot;docker_network&amp;quot; &amp;quot;vlan&amp;quot; {
  name   = &amp;quot;ipvlan&amp;quot;
  driver = &amp;quot;ipvlan&amp;quot;

  ipam_config {
    subnet   = &amp;quot;10.49.1.0/24&amp;quot;
    gateway  = &amp;quot;10.49.1.1&amp;quot;
    ip_range = &amp;quot;10.49.1.0/24&amp;quot; # optional
  }

  options = {
    parent = &amp;quot;enp2s0f0&amp;quot;
  }
} 
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This worked great, and since all the devices that get an address on my vlan are mostly sending data either to the Internet or in response to data, I did not notice the problem when I expanded my subnet on my OPNsense LAN interface.&lt;/p&gt;

&lt;p&gt;Home Assistant could establish a connection when the ESPHome node announced itself, but after a few moments the connection dropped. Since the Docker vlan subnet (10.49.1.0/24) didn&amp;rsquo;t include the node&amp;rsquo;s actual IP (10.49.0.50), the routing table lacked a route back to the node&amp;rsquo;s IP, preventing reconnection. So even though the node had the correct subnet and could communicate, Home Assistant&amp;rsquo;s network interface couldn&amp;rsquo;t route back to it.&lt;/p&gt;

&lt;p&gt;Once I diagnosed the problem, the fix was easy:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;resource &amp;quot;docker_network&amp;quot; &amp;quot;vlan&amp;quot; {
  name   = &amp;quot;ipvlan&amp;quot;
  driver = &amp;quot;ipvlan&amp;quot;

  ipam_config {
    subnet   = &amp;quot;10.49.0.0/22&amp;quot;
    gateway  = &amp;quot;10.49.1.1&amp;quot;
    ip_range = &amp;quot;10.49.0.0/22&amp;quot; # optional
  }

  options = {
    parent = &amp;quot;enp2s0f0&amp;quot;
  }
} 
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now containers on my vlan know about my entire network and have correct routing tables. My ESPHome node is now stable and I am back to mostly just cursing at YAML.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Why Did The Devs Do That?</title>
    <link>https://connermccall.com/blog/2026/01/23/why-did-it-happen/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/01/23/why-did-it-happen/</guid>
    <description>A deeper dive into a Hacker News question I liked</description>
    <pubDate>Fri, 23 Jan 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I saw the question &lt;a href=&#34;https://news.ycombinator.com/item?id=46726249&#34; target=&#34;_blank&#34;&gt;&amp;ldquo;How do you find the &amp;ldquo;why&amp;rdquo; behind old code decisions?&amp;rdquo;&lt;/a&gt; on Hacker News and had to respond.&lt;/p&gt;

&lt;p&gt;This is what &lt;a href=&#34;https://news.ycombinator.com/item?id=46731749&#34; target=&#34;_blank&#34;&gt;I said&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The honest answer is you probably won&amp;rsquo;t find it. Historical documentation is hard, it is the first &amp;ldquo;features&amp;rdquo; cut when teams are scrambling to meet a deadline. There is no malice in this, it&amp;rsquo;s just something that the end user doesn&amp;rsquo;t need or see so when shit hits the fan, it get skipped.
&lt;/br&gt;&lt;/br&gt;Commit logs, slack/email/etc, documentation silos, or issue trackers are your best bet, other than actually being able to talk to the author(s) of the code.
&lt;/br&gt;&lt;/br&gt;But in general, the decision was made because in the time the developer had to implement the feature or fix, this was the best solution they could come up with. Hopefully if there were clear tradeoffs, there is some comment as to what they might have done with more time. Likely though they were rushed, told their team they wanted to go back and fix this, and then were ushered into a new project the second this one stabilized.
&lt;/br&gt;&lt;/br&gt;I think &lt;a href=&#34;https://news.ycombinator.com/item?id=46731382&#34; target=&#34;_blank&#34;&gt;gghhjnuhbb&lt;/a&gt; has the best alternative to finding actual documentation and that is sitting and putting yourself in their headspace. That can sometimes lead to insights you might have missed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I had more to say, so I will say it here.&lt;/p&gt;

&lt;p&gt;These days I spend 90% of my working time on a digital art project with over 30 years of development history. Before a single line of code was written, the creator of the project spent 20+ years thinking about it. So 50ish years of decisions are sitting on my machine right now, both development and artistic.&lt;/p&gt;

&lt;p&gt;The code base is made up of several repos, most of which were messily transitioned from svn to git at one point. History was lost, documentation is scattered in an &lt;a href=&#34;/blog/2024/01/02/svn-restoration&#34;&gt;old svn repository&lt;/a&gt;, Jira, a archived &lt;a href=&#34;https://trac.edgewall.org/&#34; target=&#34;_blank&#34;&gt;Trac&lt;/a&gt; project, git markdown files, inaccessible Slack channels, Jabber chats, and meeting notes lost to the bitrot of time.&lt;/p&gt;

&lt;p&gt;So how do I consider previous decisions when I am modifying code written by developers who are long since departed this codebase?&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I look for comments in the source. I also read the code. With a decade on this project, I occasionally have flashes of insight. Usually it involves the client&lt;sup&gt;1&lt;/sup&gt; changed their mind. (Honestly this is the answer 89% of the time on all projects. If you think there is not a client, you need to look harder).&lt;/li&gt;
&lt;li&gt;I ask the client. This can be useful and sometimes leads to deeper understanding of the intention of a feature that was marked as completed in the past. For most projects this is probably only a few months, but if you are learning about a code base, the more business/human context you have the better.&lt;/li&gt;
&lt;li&gt;Old emails and Issues are also good resources, if you have a way to search them. Hopefully you will get a comment thread where the developer or developers are clarifying expectations as they go. But usually all you will get is a title, maybe a reasonable description, and then a marked completed activity.&lt;/li&gt;
&lt;li&gt;VCS history is very rarely super useful in figuring out why without additional context in my experience. Only on projects where VCS hygiene was extremely vigorous do commits and commit messages signal why. Usually they end up being a mix of; fixed a bug, whoops, ci commit, ci commit, etc. But &lt;code&gt;git blame&lt;/code&gt; can at least help you when searching old emails or issues.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The truth is that even after doing all of this, there is a better chance I have to just guess at intentions. They were probably good, they may have been misguided, rushed, or not backed by as much skill as we might like. Someone asked for something, the developer delivered it, and if it&amp;rsquo;s been in the code for more than a few weeks, it probably solved a real problem.&lt;/p&gt;

&lt;p&gt;In the end, the why something happened is not the important bit anyway. Why can help you feel good or worse about something. But today&amp;rsquo;s problem is what is important. Instead of wondering why they (and let&amp;rsquo;s be honest, you) did something 6 months ago, instead focus on what the impact of changing this decision will be. And leave a comment and write a good commit message, do better than the past.&lt;/p&gt;

&lt;p&gt;&lt;sup&gt;1&lt;/sup&gt; Client may be singular but can be made up of numerous individuals. Someone, maybe just you the developer, is the client.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Slow Horses -> Slough House</title>
    <link>https://connermccall.com/blog/2026/01/21/slough-house-novels/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/01/21/slough-house-novels/</guid>
    <description>When the books are great; Thank you Apple for the intro</description>
    <pubDate>Wed, 21 Jan 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;Several months ago I decided to check out &lt;a href=&#34;https://thetvdb.com/series/slow-horses&#34; target=&#34;_blank&#34;&gt;Slow Horses&lt;/a&gt;. This is a drama on Apple TV that has gotten solid attention. It has won several awards and since it is a &amp;ldquo;spy&amp;rdquo; story, it intrigued me. It also has Gary Oldman, who I tend to enjoy, but the first few episodes didn&amp;rsquo;t grab my attention and I dropped it for other things.&lt;/p&gt;

&lt;p&gt;Then I learned that it was based on a book series called &lt;a href=&#34;https://www.mickherronbooks.com/slough-house-series&#34; target=&#34;_blank&#34;&gt;Slough House&lt;/a&gt;. I saw one of Mitch Herron&amp;rsquo;s books on a table at Barnes and Nobels with a promo sticker referring to the show. I was curious, especially when a few reviews mentioned &lt;a href=&#34;https://en.wikipedia.org/wiki/John_le_Carr%C3%A9&#34; target=&#34;_blank&#34;&gt;John le Carré&lt;/a&gt; as a comparison.&lt;/p&gt;

&lt;p&gt;It turns out the books are great. They are a perfect blend of dry humor, spy craft, mystery, and suspense. Nearly every character is deeply flawed, yet you can&amp;rsquo;t help but root for them to get through whatever bizarre circumstance this ragtag bunch finds themselves caught up in.&lt;/p&gt;

&lt;p&gt;I have blown through the first three books and just started the fourth, Spook Street. If you are a fan of le Carré, slightly messy heroes(?), or dry humour, I&amp;rsquo;d pick up Slow Horses and give it a read. There are currently nine books and a few novellas. I would recommend following the order. The characters grow and change with each book, even if the throughlines in the stories are not particularly broad.&lt;/p&gt;

&lt;p&gt;Heck, I have enjoyed them so much I might go back and give the TV show another shot. Sometimes seeing how the screenwriters turn a book into a show adds to the fun.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Cleaning out the Cobwebs #1</title>
    <link>https://connermccall.com/blog/2026/01/13/cobwebs/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/01/13/cobwebs/</guid>
    <description>Various updates that don't merit a full post</description>
    <pubDate>Tue, 13 Jan 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;&lt;em&gt;Inspired by a few things, but primarily &lt;a href=&#34;https://www.seerofsouls.com/cliffs-notes-episode-2-of-2026/&#34; target=&#34;_blank&#34;&gt;Cliff&amp;rsquo;s Cliff Notes&lt;/a&gt;. I don&amp;rsquo;t intend on these being on any specific cadence, but just a way to dump some thoughts that I have been toying around with but don&amp;rsquo;t feel like fleshing out.&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I broke my shoulder the Friday before Christmas in a fall on the ice. I finally went to the Dr. three weeks in, had an MRI. We found that I broke my &lt;a href=&#34;https://en.wikipedia.org/wiki/Greater_tubercle&#34; target=&#34;_blank&#34;&gt;greater tuberosity&lt;/a&gt;. My left arm is in a sling and I start PT soon. Thankfully it was not a torn rotator cuff, which was the initial fear of my Dr.&lt;/li&gt;
&lt;li&gt;I had to stop skating lessons due to this, too much risk of a major injury if I was to fall. I am sad, but learned there are summer classes. I setup a changedetection.io watcher on the lesson search page so I can be notified when they open. Works as both a way to ensure I get in and as a reminder to actually sign up. This little tool is fun to find uses for.&lt;/li&gt;
&lt;li&gt;Sports have been fun the last few weeks. College Football playoffs have been interesting, if kind of boring on the field. NFL playoffs were insane this last weekend, so much fun. Wolves are rolling and Wild, though struggling are fun with their big acquisition of Quinn Hughes. Not a bad set of circumstances when I am trying to stay mostly still.&lt;/li&gt;
&lt;li&gt;In other sports news. Saint Paul was the host of the &lt;a href=&#34;https://www.iihf.com/en/events/2026/wm20&#34; target=&#34;_blank&#34;&gt;World Juniors hockey tournament&lt;/a&gt;. We were able to snag tickets to one of the USA games. It was a lot of fun. The game and the events around it were great. I really hope they can host again in the next decade.
&lt;img src=&#34;/blog/2026/01/images/gc-iihf.jpg&#34; /&gt;&lt;/li&gt;
&lt;li&gt;My injury has definitely made typing more challenging. I am still looking for other options, but I discovered &lt;a href=&#34;https://github.com/cjpais/Handy&#34; target=&#34;_blank&#34;&gt;Handy&lt;/a&gt;, which is pretty nice. Lets me hit a button on my keyboard and speak to put text into any text field. It is not perfect, but for quick notes and thoughts it does enough.&lt;/li&gt;
&lt;li&gt;I snagged a refurbished OLED Steam Deck after my changedetection.io instance alerted me to availability. Tough to play much with a single arm, but I am excited about the possibilities. Poked at &lt;a href=&#34;https://www.blueprincegame.com/&#34; target=&#34;_blank&#34;&gt;Blue Prince&lt;/a&gt; for an hour or so today, which is a great title when you can be relaxed with your reaction times. I am not sure I quite get the game yet, but I have yet to feel stuck.&lt;/li&gt;
&lt;li&gt;This will inspire a longer post, but I am also working on an esphome backed fireplace fan controller. Basically I want to replace the fan we bought when we moved in with a quieter, controllable version. Today I got the first fan wired up correctly so I can control the speed through Home Assistant. Running into some connectivity issues where the esp32 loses communication with Home Assistant, which is a weird bug I will figure out.&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item>

<item>
    <title>Self Hosted Slashpage</title>
    <link>https://connermccall.com/blog/2026/01/05/home-lab/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/01/05/home-lab/</guid>
    <description>Adding a new slashpage to my site</description>
    <pubDate>Mon, 05 Jan 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;&lt;a href=&#34;/blog/2024/01/05/home-lab/&#34;&gt;Two years ago&lt;/a&gt; I posted information on my homelab. Today I am publishing a new slashpage &lt;strong&gt;&lt;a href=&#34;/self-hosted&#34;&gt;/self-hosted&lt;/a&gt;&lt;/strong&gt; which will be the new place to showcase the services I am hosting. I hope to keep this updated at least quarterly. I have plans on putting more information on how I am hosting the various services as well as the list of services.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Addressing Annoying Wiring with Smart Devices</title>
    <link>https://connermccall.com/blog/2026/01/03/shelly-dimmer-2-wiring/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2026/01/03/shelly-dimmer-2-wiring/</guid>
    <description>How I used a Shelly Dimmer 2 as an inline dimmer</description>
    <pubDate>Sat, 03 Jan 2026 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;This fall, we did some updates to our dining room. Along with adding a &lt;a href=&#34;/blog/2025/12/25/hello-again/#bar&#34;&gt;fun fake brick wall&lt;/a&gt; to a closet we repurposed for a dry bar, we replaced our lighting with much brighter lights.&lt;/p&gt;

&lt;p&gt;The wiring in the room had a two way switch for one of the lights with an outlet in between. This outlet was not in a great place for lights or really any device you might want to control with a switch. In fact the main purpose we put it to was vacuuming. Regardless we do not use the outlet much, but we also wanted a dimmer. Installing a dimmer switch with a standard 110V outlet downstream will damage either the equipment or the switch.&lt;/p&gt;

&lt;p&gt;So my ideal scenario was that we would wire the outlet directly to mains bypassing the switch. The existing wiring made this impossible without opening walls. Not a project I was interested in starting, but maybe one day.&lt;/p&gt;

&lt;p&gt;After some brainstorming I realized that having Home Assistant could give me a solution.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I installed a &lt;a href=&#34;https://us.shelly.com/products/shelly-dimmer2&#34; target=&#34;_blank&#34;&gt;Shelly Dimmer 2&lt;/a&gt; behind the light fixture. This allowed me to control the on/off state of the light and dim it through Home Assistant.&lt;/li&gt;
&lt;li&gt;Next I installed new Zigbee compatible switches, but I left the load disconnected. Instead I wired the hot wire directly from the main to both the outlet and switch load wires, keeping both always powered. This meant the outlet was always powered as was the Shelly Dimmer.&lt;/li&gt;
&lt;li&gt;Finally, I created new Home Assistant automations that mirrored the state of the Zigbee switch to the Shelly Dimmer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Overall this has worked really well. I do wish that the dimming was a bit smoother, there is definitely a bit of a hiccup between when the zigbee switch reports it is dimming and the Shelly gets updated. And occasionally there is a brief stutter from toggling off/on and the light responding. But in almost 4 months we have had only a couple instances of it getting out of sync or failing to respond.&lt;/p&gt;

&lt;p&gt;This modification has broken one of my rules for Home Automation, which is that all devices should be functional even if the smarts go out. If we lose wifi or Home Assistant goes down, the light stays in its current state until service is restored. But rules are meant to be broken and being able to dim the lights and also fix the outlet/light dependency made breaking this rule acceptable.&lt;/p&gt;

&lt;p&gt;One other thing this has led me to do is create a wiki for our home. It is a work in progress, but my end goal is a manual for all the weird quirks we either create ourselves or find as we continue this homeownership journey.&lt;/p&gt;

&lt;p&gt;Here is the template for the automation if you are interested.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;description: &amp;quot;match state of dimmer switch to state of shelly dimmer&amp;quot;
triggers:
  - entity_id:
      - light.dining_room_north_dimmer_switch
    trigger: state
conditions: []
actions:
  - if:
      - condition: device
        type: is_off
        device_id: light.dining_room_north_dimmer_switch
        entity_id: light.dining_room_north_dimmer_switch
        domain: light
    then:
      - type: turn_off
        device_id: light.dining_room_north_dimmer
        entity_id: light.dining_room_north_dimmer
        domain: light
    else:
      - action: light.turn_on
        target:
          entity_id: light.dining_room_north_dimmer
        data:
          brightness: &amp;gt;-
            {{state_attr(&#39;light.dining_room_north_dimmer_switch&#39;,
            &#39;brightness&#39;)}}
mode: single
&lt;/code&gt;&lt;/pre&gt;
</content:encoded></item>

<item>
    <title>2025 - Food I Remember</title>
    <link>https://connermccall.com/blog/2025/12/29/food-i-remember/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2025/12/29/food-i-remember/</guid>
    <description>A list of things I have memories of eating this year</description>
    <pubDate>Mon, 29 Dec 2025 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I have been in a bit of a &amp;ldquo;work&amp;rdquo; funk and have been spending maybe a bit too much time clicking around Kagi&amp;rsquo;s small web feature. That has led to several year in review posts, which can be really fun, even if you are not familiar with the writer.&lt;/p&gt;

&lt;p&gt;I started to think about what I remember about 2025, I did a what I have done post, but the other thing I like to think about is, meals that I remember. Maybe this becomes an annual tradition.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Whipped Feta and Calamari at &lt;a href=&#34;https://www.balaboostanyc.com/menus&#34; target=&#34;_blank&#34;&gt;Balaboosta&lt;/a&gt; - After a detour to a Jonathan Waxman restaurant, we arrived here, which was our original plan. The whole meal was amazing, but the calamari, which was not fried and the feta, which was like a dream still resonate with me.&lt;/li&gt;
&lt;li&gt;Prosciutto &amp;amp; Burrata at &lt;a href=&#34;https://www.barbutonyc.com/&#34; target=&#34;_blank&#34;&gt;Barbuto&lt;/a&gt; - Due to a naming mishap, I tried to spell Balaboosta and managed to get closer to Barbuto and failed to verify. We ended up eating one of the most ridiculous Prosciutto and Burrata dishes. Just amazing cheese draped with amazing cured pork.&lt;/li&gt;
&lt;li&gt;Scallop Crudo at &lt;a href=&#34;https://www.spoonandstable.com&#34; target=&#34;_blank&#34;&gt;Spoon &amp;amp; Stable&lt;/a&gt; - The whole dinner was amazing, but this was so light and so refreshing. It was one of the few times I have had tepache and it was a great introduction.&lt;/li&gt;
&lt;li&gt;Cachapa at &lt;a href=&#34;https://www.crasquirestaurant.com&#34; target=&#34;_blank&#34;&gt;Crasqui&lt;/a&gt; - Cheesy pancake goodness. I really do not have much else to say.&lt;/li&gt;
&lt;li&gt;My Brisket on Thanksgiving - I have made brisket several times, but this one was just perfect. almost 20 hours on the grill with a 6 hour rest. Worth every moment of my time.&lt;/li&gt;
&lt;li&gt;Hot Dogs at &lt;a href=&#34;https://rudysbarnyc.com/&#34; target=&#34;_blank&#34;&gt;Rudy&amp;rsquo;s&lt;/a&gt; - The whole experience was perfect. NYC is such a great place to visit, and when you find beer for $4, you have to go. But the kicker is you get a free hot dog with every drink and they are actually good. Anthony Bourdain filmed here and we get it.&lt;/li&gt;
&lt;li&gt;&amp;ldquo;Redemption&amp;rdquo; Paisano at &lt;a href=&#34;https://pizzeriapezzo.com&#34; target=&#34;_blank&#34;&gt;Pezzo&lt;/a&gt; - Pezzo is one of my favorite hometown spots. Great pizza, good wine, nice people. But mid-year we stopped in and it was not right. The only thing good was the bar service. But they changed ownership and we went back and it was the old experience. They did not call it Redemption, but it is the story I tell myself.&lt;/li&gt;
&lt;li&gt;Barramundi at &lt;a href=&#34;https://www.stpierrerestaurant.com/menu&#34; target=&#34;_blank&#34;&gt;St. Pierre&lt;/a&gt; - I had remembered &lt;a href=&#34;https://www.bravotv.com/people/massimo-piedimonte&#34; target=&#34;_blank&#34;&gt;Massimo&lt;/a&gt; making a dish with crazy water on Top Chef and being intrigued. This was so, so good.&lt;/li&gt;
&lt;li&gt;Oysters at &lt;a href=&#34;https://smack-shack.com/&#34; target=&#34;_blank&#34;&gt;Smack Shack&lt;/a&gt; - Mostly because I shucked them myself, but all 12 were delicious&lt;/li&gt;
&lt;li&gt;Bolognese at &lt;a href=&#34;https://acquawbl.com&#34; target=&#34;_blank&#34;&gt;Acqua&lt;/a&gt; - Another local favorite, I might actually go check for reservations for NYE right now.&lt;/li&gt;
&lt;li&gt;A pretzel from &lt;a href=&#34;https://akisbreadhaus.com/&#34; target=&#34;_blank&#34;&gt;Aki&amp;rsquo;s Breadhouse&lt;/a&gt; - Eaten outside while listening to 4 on the Floor.&lt;/li&gt;
&lt;li&gt;The eggrolls at Thai Boat 89 - I went for the Pho and ordered this for some reason and wow.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A few other hits; Momos at Marketfest, Hoagie from Cup n&amp;rsquo; Cone, Pocket Full of Soul from Ville City Pizza Co, the perfect Tex Mex Nachos at Pinehaven farm, savory cannoli at Liliana, dumplings at Thep Thai.&lt;/p&gt;

&lt;p&gt;I have no photos of this food. I never remember(or really want) to take photos of my food. But maybe this coming year I can remember to photograph menus consistently. That is more interesting to me anyway.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Using Docker VLANs</title>
    <link>https://connermccall.com/blog/2025/12/22/docker-vlans/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2025/12/22/docker-vlans/</guid>
    <description>Stumbling on new features to solve old problems</description>
    <pubDate>Mon, 22 Dec 2025 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;My &lt;a href=&#34;/blog/2024/01/05/home-lab/&#34;&gt;homelab&lt;/a&gt; is the unfeeling brain of my home. Without it we would survive, but boy would we miss some things. Two of the most important are Homeassistant and Pihole.&lt;/p&gt;

&lt;p&gt;A few months ago I was troubleshooting some DNS issues on my internal LAN. More often than I would like, DNS was super slow and local name resolution was either not working at all, or working randomly.&lt;/p&gt;

&lt;p&gt;My DNS is setup so that local clients should ask Pi-Hole for the address first. If Pi-Hole does not have the address on a block list and does not have it cached, it hits Unbound DNS. Pi-Hole lives on my NAS in a docker container and Unbound is a service on my edge router running OPNsense. I register any local names on OPNsense, allowing Pi-Hole to focus on blocking and caching.&lt;/p&gt;

&lt;p&gt;My issue is that exposing port 53 for both TCP and UDP was not reliable. Pi-Hole&amp;rsquo;s &lt;a href=&#34;https://docs.pi-hole.net/docker/&#34; target=&#34;_blank&#34;&gt;official documentation&lt;/a&gt; recommends assigning &lt;code&gt;NET_ADMIN&lt;/code&gt; as a capability for Pi-Hole. &lt;code&gt;NET_ADMIN&lt;/code&gt; grants broad control over the network stack, and I wasn&amp;rsquo;t comfortable giving Pi-Hole those elevated privileges just to bind DNS ports.&lt;/p&gt;

&lt;p&gt;For Home Assistant, I had purchased a Tempest weather station, which is wonderful because it can operate fully locally. It broadcasts the data over multicast UDP. Which is a little weird, but also allows anything you wish to have locally read the data. Since Home Assistant was on its own docker network it was unable to listen to these UDP packets and find the device.&lt;/p&gt;

&lt;p&gt;Enter &lt;a href=&#34;https://docs.docker.com/engine/network/drivers/ipvlan/&#34; target=&#34;_blank&#34;&gt;ipvlan&lt;/a&gt;. By default, Docker isolates containers in their own network, which blocks multicast and broadcast packets from reaching them. ipvlan lets containers get real IP addresses directly on your physical LAN instead, so they can receive all the network traffic they need. Since I use &lt;a href=&#34;https://opentofu.org/&#34; target=&#34;_blank&#34;&gt;OpenTofu&lt;/a&gt;, in my &lt;code&gt;.tf&lt;/code&gt; config I created the network:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;resource &amp;quot;docker_network&amp;quot; &amp;quot;vlan&amp;quot; {
  name   = &amp;quot;ipvlan&amp;quot;
  driver = &amp;quot;ipvlan&amp;quot;

  ipam_config {
    subnet   = &amp;quot;10.49.1.0/24&amp;quot;
    gateway  = &amp;quot;10.49.1.1&amp;quot;
    ip_range = &amp;quot;10.49.1.0/24&amp;quot; # optional
  }

  options = {
    parent = &amp;quot;enp2s0f0&amp;quot;
  }
} 
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then in any container where I would like it to have a real IP address, I added:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;networks_advanced {
    name         = docker_network.vlan.name
    ipv4_address = &amp;quot;10.49.1.99&amp;quot;
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;&lt;em&gt;&lt;strong&gt;Updated 2026-01-27&lt;/strong&gt;: Be sure to keep your subnet updated to match your network topology or you might find &lt;a href=&#34;/blog/2026/01/28/docker-vlan-netmask/&#34;&gt;weird issues&lt;/a&gt; down the road&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Now instead of assigning my NAS&amp;rsquo;s primary IP as DNS I was able to assign 10.49.1.99 and Pi-Hole had full access to the network over UDP, multicast, and TCP. By doing the same for Home Assistant I was not only able to discover and easily add my Tempest device, but I also found that using Chromecast devices was much more stable.&lt;/p&gt;

&lt;p&gt;While this does expose the container more directly to the network, it removes the security hole that granting a service &lt;code&gt;NET_ADMIN&lt;/code&gt; permissions opens up, only exposing the container to the network instead of giving the container the right to modify your networks.&lt;/p&gt;

&lt;p&gt;I still access the web interfaces for both these services via proxy; this gives me SSL and allows me to restrict access to only local network devices, either on my physical LAN or via Wireguard.&lt;/p&gt;

&lt;p&gt;Notes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;enp2s0f0&lt;/code&gt; is a secondary network device on my NAS. You can use any physical network device on your machine. I happened to have a second network device that was barely utilized.&lt;/li&gt;
&lt;li&gt;Technically both services can be accessed via their web interface over their assigned IP. Ideally I should probably set up firewall rules to block that completely. It would be trivial to do this with &lt;code&gt;ufw&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;You can also mark the vlan as internal and tag the traffic for 802.1Q isolation. I have not used this, but it is on the &amp;ldquo;want to explore&amp;rdquo; list.&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item>

<item>
    <title>YNAB - A Tool I Love</title>
    <link>https://connermccall.com/blog/2025/12/18/ynab/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2025/12/18/ynab/</guid>
    <description>10 Year of You Need a Budget</description>
    <pubDate>Thu, 18 Dec 2025 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I recently had a friend bring up some efforts they were making to change how they budget. I immediately jumped in and told them I use &lt;a href=&#34;https://ynab.com&#34; target=&#34;_blank&#34;&gt;YNAB&lt;/a&gt; and that it was a life changer for me. I was working on a recommendation email for them and this blog post fell out.&lt;/p&gt;

&lt;hr&gt;

&lt;p&gt;I have been using YNAB for 10 years. This means I started with the fully offline version and migrated to the web app, which meant a monthly subscription, a jarring change at the time. Now, it is one of those things I am happy to pay for every year. It gives me significant peace of mind. I say that even as I try to move more and more services to be self-hosted, nothing I have found has made the trade-offs worth it.&lt;/p&gt;

&lt;h3 id=&#34;but-why-do-i-love-and-pay-for-ynab&#34;&gt;But why do I love and pay for YNAB?&lt;/h3&gt;

&lt;p&gt;At its core, the system is just an envelope system: set aside money for a specific thing, spend from that envelope when it&amp;rsquo;s time. Then around that core function is the philosophy. The app does not enforce things; it&amp;rsquo;s just there to help you pull it off. These days they call the philosophy &lt;a href=&#34;https://www.ynab.com/ynab-method&#34; target=&#34;_blank&#34;&gt;The Method&lt;/a&gt;. I distill it into:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every dollar you actually earn needs to be given a job.&lt;/li&gt;
&lt;li&gt;Do not anticipate earning a dollar, even if you expect a check to land tomorrow, it does not exist until you have it in your account.&lt;/li&gt;
&lt;li&gt;You should expect adjustments; your budget is not written in stone.&lt;/li&gt;
&lt;li&gt;The better you know your spending, the easier it is to put money into that envelope today for spending in the future. It helps avoid scrambling to figure out how to pay for predictable but irregular expenses. &lt;br&gt;
&lt;/li&gt;
&lt;li&gt;Plan for fun spending and actually spend it. For example, I have an &amp;ldquo;Allowance&amp;rdquo; for both me and my wife every month. It has no rules; it&amp;rsquo;s just there in case either of us spends on something unexpected, which happens pretty much every month. Your budget should not be limiting, it should be freeing, at least eventually. &lt;br&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The reason YNAB clicked with me back when I was younger and dumber was that it preached flexibility. So I would often say something like, &amp;ldquo;hey, this month I had to replace my tires, so I didn&amp;rsquo;t get to put money in my vacation bucket&amp;rdquo; That&amp;rsquo;s fine, even if it is disappointing. Granted the stakes are different now that I own a home and share a life with a spouse, but the same philosophy applies. They used to call this &lt;em&gt;Roll with the Punches&lt;/em&gt;. It was extremely freeing since it didn&amp;rsquo;t make me feel like a failure if I had to adjust mid-month. It also helped me cushion for predictable expenses. The first year, every time an unexpected, but predictable, expense popped up, it was a &lt;em&gt;Roll with the Punches&lt;/em&gt; moment since I did not have money ready for it. But then I created a new category and set aside money every month to pay that expense when it would resurface. Yes, even my driver&amp;rsquo;s license has a category. I put aside $1.58 a month. Insane? Yes. Freeing? Yes.&lt;/p&gt;

&lt;p&gt;The big actions are creating categories and then setting goals for that category. Goals can be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The exact dollar amount of a bill (your mortgage, cell phone bill)&lt;br&gt;
&lt;/li&gt;
&lt;li&gt;An amount to set aside to spend that month that will likely fluctuate (groceries, dining out)&lt;br&gt;
&lt;/li&gt;
&lt;li&gt;An amount to save for a future known expense (insurance premium, Costco membership)&lt;br&gt;
&lt;/li&gt;
&lt;li&gt;An amount to save for some unknown date (unexpected house expenses, travel). &lt;br&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I have my system down so that when I get a paycheck, I can mostly just have the system auto-distribute it, but that took a lot of time honing my budget. It takes time to make the software work for you, but that time is hopefully spent adjusting your relationship with money. It did for me.  I spent the first 10+ years of my adult life fighting with money. It never seemed like I had enough and I was always on the cliff. Part of what changes is I got lucky and got a job in development, but the other change was YNAB. Figuring out how to track and understand my spending through their lens was a gamechanger. I highly recommend giving it a shot.&lt;/p&gt;

&lt;p&gt;If you like this post and want to give it a try, this &lt;a href=&#34;https://ynab.com/referral/?ref=HqlTibHV5MfFb4vW&amp;amp;sponsor_name=Conner&amp;amp;utm_source=customer_referrall&#34; target=&#34;_blank&#34;&gt;referral link&lt;/a&gt; will get us both 1 month free if you sign up at the end of your trial.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Hello Again</title>
    <link>https://connermccall.com/blog/2025/12/25/hello-again/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2025/12/25/hello-again/</guid>
    <description>I stopped blogging and did some things</description>
    <pubDate>Tue, 16 Dec 2025 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;My last post was back in April, not exactly meeting my goal of a post a week. But I have made a few changes to reduce the friction of publishing posts. In the meantime, I was busy this summer. A few things that kept me busy.&lt;/p&gt;

&lt;h3 id=&#34;patio-project&#34;&gt;Patio Project&lt;/h3&gt;

&lt;p&gt;In May we tore up our old brick patio. Several months later I laid the final brick on our new patio. This was an intense project, and maybe something I should have hired out. But the pride I get when I look at it now is worth the blood, sweat (so much sweat), aches and pains.&lt;/p&gt;

&lt;p&gt;Before, we had red bricks that were extremely uneven, lacked drainage, and had lost any semblance of a border.&lt;/p&gt;

&lt;p&gt;Now we have brand new pavers on top of 8 inches of tamped down class 5 and leveling sand. We expanded it quite a bit by going straight across instead of &amp;ldquo;curving&amp;rdquo; like our old patio did.&lt;/p&gt;

&lt;p&gt;In addition, we installed a drainage system. Our yard and house grade is a little wonky, so to make the patio sit above the grade of the yard we had to slope it inwards. So under the middle of the patio we installed a 50 gallon drain barrel with an overflow to the side yard. I installed a hidden drain which worked well all summer. I might write a longer post on this in the spring if it works well during winter and the thaw.&lt;/p&gt;

&lt;div class=&#34;grid grid-cols-2 gap-2&#34;&gt;
    &lt;img src=&#34;/blog/2025/12/images/old_patio.jpg&#34; alt=&#34;Photo of Original Patio&#34; /&gt;
    &lt;img alt=&#34;Photo of me digging a trench&#34; src=&#34;/blog/2025/12/images/ditch.jpg&#34; /&gt;
    &lt;img alt=&#34;Photo of Fill&#34; src=&#34;/blog/2025/12/images/in_progress.jpg&#34; /&gt;

    &lt;img alt=&#34;Photo of New Patio&#34; src=&#34;/blog/2025/12/images/finished.jpg&#34; /&gt;
&lt;/div&gt;

&lt;h3 id=&#34;a-id-retaining-wall-a-retaining-wall&#34;&gt;&lt;a id=&#34;retaining_wall&#34;&gt;&lt;/a&gt;Retaining Wall&lt;/h3&gt;

&lt;p&gt;The back of our back yard ends in a steep drop to an alley. There must have been a retaining wall there years ago, but by the time we moved in it was gone. It was an overgrown, uneven eyesore, that was slowly eroding away. We cleaned it up by installing a new wooden retaining wall and extending the concrete block wall along the driveway. I expect we will need to replace it within 10 years, but it is a significant improvement at a reasonable cost. Excited to plant some native flowers in the spring.&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2025/12/images/retaining_wall-progress.jpg&#34; alt=&#34;Photo of In Progress Retaining Wall&#34; /&gt;&lt;/p&gt;

&lt;h3 id=&#34;asl&#34;&gt;ASL&lt;/h3&gt;

&lt;p&gt;At the prompting of my friend and business partner, I enrolled in my first college class in 20-ish years. I just wrapped up an accelerated version of ASL 1 and ASL 2 at Saint Paul College. This was a challenge, but I mostly enjoyed it. American Sign Language is such an expressive and fun way to communicate. I am looking for other ways to continue my learning, as another accelerated course is not in the cards for next year.&lt;/p&gt;

&lt;h3 id=&#34;a-id-bar-a-converted-a-closet-to-a-bar&#34;&gt;&lt;a id=&#34;bar&#34;&gt;&lt;/a&gt;Converted a Closet to a Bar&lt;/h3&gt;

&lt;p&gt;Our dining room came with a coat closet, which makes some sense if we ever used our front door, which we do not. So my wife designed a bar and we made it happen. The brick is glued on, then we used actual mortar. The cabinets were custom made by a local company and they did great work. The light came from our last home. We had purchased it for the dining room then and still loved it, but could not figure out a good location for it herer. So this was a double win.&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2025/12/images/bar.jpg&#34; alt=&#34;Photo of New Bar&#34;&gt;&lt;/p&gt;

&lt;h3 id=&#34;oyster-shucking&#34;&gt;Oyster Shucking&lt;/h3&gt;

&lt;p&gt;It was just one evening, but I took an oyster shucking class on National Oyster Day. It was easier than I anticipated, and something I would be happy to do again. It was hosted by Smack Shack and came with several beverage pairings.&lt;/p&gt;

&lt;h3 id=&#34;yard-destruction&#34;&gt;Yard Destruction&lt;/h3&gt;

&lt;p&gt;One of the outcomes of the patio build was the yard between my fence and the street was torn up. I had dreamed of getting rid of the turf grass there and took the opportunity to cover it in mulch. If you are ever looking for wood chips, Chip Drop is amazing.&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;/blog/2025/12/images/chips.jpg&#34; alt=&#34;Photo of sideyard covered in wood chips&#34;&gt;
I have several post ideas in my head, so hopefully I can end 2025 and start 2026 with some interesting content.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Check your Groups</title>
    <link>https://connermccall.com/blog/2025/04/08/gpu-groups/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2025/04/08/gpu-groups/</guid>
    <description>Troubleshooting a transcoding mystery</description>
    <pubDate>Tue, 08 Apr 2025 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;In preparation for rebuilding my home server with some parts sourced from eBay, I made a point to upgrade to the latest LTS version of Ubuntu. I was two versions behind, not yet out of support, but with the new hardware I was inspired to make the move.&lt;/p&gt;

&lt;p&gt;In general, upgrades on &lt;em&gt;fungi&lt;/em&gt; (my server) are fairly painless. &lt;a href=&#34;blog/2024/01/05/home-lab/&#34; target=&#34;_blank&#34;&gt;All my services &lt;/a&gt; run in Docker containers, so the underlying OS has a limited role. At first, everything seemed to go smoothly. My services were running, I could access everything, and there did not appear to be any surprises.&lt;/p&gt;

&lt;p&gt;A few days later, I got a note from my mother-in-law saying she was having trouble accessing some content I host. I run Plex and have a small router at their house that creates a WireGuard connection back to my server, letting their Roku stream content from my house. It has worked flawlessly for a long time, until suddenly it did not. I could not isolate the problem and was left saying, “I don’t know,” which never inspires confidence in your end-users.&lt;/p&gt;

&lt;p&gt;I did some troubleshooting but could not find a clear reason for the issue. Then, I started running into playback issues while watching a movie at home. I quickly logged into &lt;em&gt;fungi&lt;/em&gt; from my phone, and &lt;em&gt;BINGO&lt;/em&gt; &lt;code&gt;htop&lt;/code&gt; showed Plex transcoding was chewing up my CPU. This was strange, because I have an older GPU installed specifically to avoid this.&lt;/p&gt;

&lt;p&gt;It did not take long to figure out that during one of the major version upgrades, Ubuntu had changed the group ownership of the video card device. Plex could see the GPU but could not use it. A quick &lt;code&gt;usermod -aG video plexmediaserver &amp;amp;&amp;amp; docker restart plexmediaserver&lt;/code&gt; later, CPU usage dropped to nothing, and I could see my GPU calmly sipping on MP4 frames.&lt;/p&gt;

&lt;p&gt;I am a little annoyed that Plex gave no indication anything was wrong. I had checked the transcoding settings, and the GPU was selected and nothing suggested it was unusable. I would have preferred a loud failure with an error message about an inaccessible transcoding device over silent fallback. But I also know the Plex team puts a lot of effort into the whole “just work, dammit” approach, which sometimes leads to these kinds of tradeoffs.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>A naming system</title>
    <link>https://connermccall.com/blog/2025/04/07/a-naming-system/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2025/04/07/a-naming-system/</guid>
    <description>You should name things on you rhome network however you want</description>
    <pubDate>Mon, 07 Apr 2025 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I think I started running a &amp;ldquo;server&amp;rdquo; in my home sometime around 2009. I honestly can&amp;rsquo;t recall what I used it for, but I know I upgraded my PC and then took the old system into our basement and hooked it up as a headless server. I have no idea what I gave it as a hostname it but I assume it was something boring.&lt;/p&gt;

&lt;p&gt;Once I had my own apartment, I remember thinking a bit more about what I was going to name my server and desktop. I do not know why, but I ended up naming things after locations in the &lt;a href=&#34;https://en.wikipedia.org/wiki/Redwall&#34; target=&#34;_blank&#34;&gt;Redwall&lt;/a&gt; book series. I know I used both &lt;em&gt;Redwall&lt;/em&gt; and &lt;em&gt;Mossflower&lt;/em&gt;. I probably used a few others but it was early 2010&amp;rsquo;s so there was not nearly as many devices connected to my home network.&lt;/p&gt;

&lt;p&gt;During my last rebuild, during Covid. I switched things up. I did some thinking and ended up deciding that everything was going to have a hostname related to food. Food is magic and I love cooking and eating. It makes naming things a bit more fun.&lt;/p&gt;

&lt;p&gt;I don&amp;rsquo;t name every device, but it is fun when I do.  sometimes it is something I just ate, sometimes it&amp;rsquo;s just a food I love. I have named systems, fungi, bucatini, burrito, cheeseburger, baobun, and burrito. The great thing is that the list of options is almost endless. I also named my wi-fi networks after food items. It is fun to tell people they can use &amp;ldquo;pepperoni pizza&amp;rdquo; to connect. A lot more fun then &amp;ldquo;Asus_WR3332&amp;rdquo;.&lt;/p&gt;

&lt;p&gt;Would I use this if  i was provisioning systems for an organization? No, the names are not meaniful to anyone but me. But for my home network the only person who cares is me, so I can have fun if i want to.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Funny enough, I just switched to using &lt;a href=&#34;https://wasabi.com&#34; target=&#34;_blank&#34;&gt;Wasabi&lt;/a&gt; as my offsite backup service. I did not choose it due to the name, but it was a nice perk.&lt;/em&gt;&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Using Gnu Parallel for chaos</title>
    <link>https://connermccall.com/blog/2025/04/01/parallel/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2025/04/01/parallel/</guid>
    <description>A quick note on parallel useage</description>
    <pubDate>Tue, 01 Apr 2025 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I’ve used &lt;a href=&#34;https://www.gnu.org/software/parallel/&#34; target=&#34;_blank&#34;&gt;Gnu Parallel&lt;/a&gt; in the past, and it’s an exceptional tool for processing a large number of tasks. I highly recommend it.&lt;/p&gt;

&lt;p&gt;Today, I started a task where we generate 5,000 things. Each task runs for about 10-15 minutes, generates a bunch of data, modifies it, and writes it elsewhere.&lt;/p&gt;

&lt;p&gt;Last week, I ran this running &lt;code&gt;parallel&lt;/code&gt; commands seperately on on five different machines. which was kind of a pain to manage. So today, when kicking off another batch of 5,000, I came up with this command:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;parallel --eta --progress --joblog jobs.log --tagstring {} --results output -j 10 --delay 10 --retries 4 -S ubuntu@10.0.0.213,ubuntu@10.0.0.66,ubuntu@1
0.0.0.58,ubuntu@10.0.0.71,ubuntu@10.0.0.14 &amp;quot;IDENTIFIER={} REALM=2 /home/ubuntu/bin/run&amp;quot; ::: {1..4999}&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Which produces this lovely output.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;Computers / CPU cores / Max jobs to run
1:ubuntu@10.0.0.71 / 16 / 10
2:ubuntu@10.0.0.14 / 16 / 10
3:ubuntu@10.0.0.213 / 16 / 10
4:ubuntu@10.0.0.58 / 16 / 10
5:ubuntu@10.0.0.66 / 16 / 10

Computer:jobs running/jobs completed/%of started jobs
ETA: 0s Left: 4999 AVG: 0.00s  1:10/0/20%/0.0s  2:10/0/
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now I’ve got 50 jobs running across five machines, and as they complete, more will start until we hit all 4,999 tasks (I already created job 0 earlier, which brings the total to 5,000).&lt;/p&gt;

&lt;p&gt;Quick breakdown.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;--eta&lt;/code&gt; outputs the expected completion time based on how long completed tasks have taken. (It currently shows 0 because no jobs have finished yet.)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--progress&lt;/code&gt; shows how far along we are&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--jobslog jobs.log&lt;/code&gt; maintains a file of completed jobs. Can be used to restart the entire run if something goes wrong.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--tagstring&lt;/code&gt; any stdout that goes to the screen get prefixed with &lt;code&gt;x&lt;/code&gt; where &lt;code&gt;x&lt;/code&gt; is the input 1-4999.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--results&lt;/code&gt; store the job stdout and stderr to files in &lt;code&gt;/output&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;-j 10&lt;/code&gt; runs up to 10 jobs on each machine&lt;/li&gt;
&lt;li&gt;&lt;code&gt;--delay 10&lt;/code&gt; wait 10 seconds before starting new  jobs.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;S ubuntu@...&lt;/code&gt; is a comma-separated list of hosts to run jobs on. You can include &lt;code&gt;localhost&lt;/code&gt; too. It’s also possible to set a specific number of jobs per host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Finally you have the command, followed by &lt;code&gt;::: {1,4999}&lt;/code&gt; In this case we are expanding the input to be all the numbers between the two digits.
Finally, the &lt;code&gt;:::&lt;/code&gt; syntax is followed by &lt;code&gt;{1..4999}&lt;/code&gt;. This expands the input to all integers in that range.&lt;/p&gt;

&lt;p&gt;This was fun and I hope you find an excuse to use parallel in the future.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Tofu/Terraform Whoopsie</title>
    <link>https://connermccall.com/blog/2025/03/28/tofu-whoopsie/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2025/03/28/tofu-whoopsie/</guid>
    <description>How to really make Tofu upset</description>
    <pubDate>Fri, 28 Mar 2025 00:00:00 UTC</pubDate>
    <content:encoded>&lt;h2 id=&#34;here-s-something-dumb-you-can-do-with-tofu-http-state&#34;&gt;Here’s something dumb you can do with Tofu + HTTP state&lt;/h2&gt;

&lt;pre&gt;&lt;code class=&#34;language-hcl&#34;&gt;terraform {
  backend &amp;quot;http&amp;quot; {
    username       = &amp;quot;${var.tfstate_username}&amp;quot;
    password       = &amp;quot;${var.tfstate_secret}&amp;quot;
    address        = &amp;quot;${var.tfstate_host}/client/${var.tfstate_team}/${var.tfstate_project}/${var.tfstate_environment}/state&amp;quot;
    lock_address   = &amp;quot;${var.tfstate_host}/client/${var.tfstate_team}/${var.tfstate_project}/${var.tfstate_environment}/lock&amp;quot;
    unlock_address = &amp;quot;${var.tfstate_host}/client/${var.tfstate_team}/${var.tfstate_project}/${var.tfstate_environment}/unlock&amp;quot;
    lock_method    = &amp;quot;POST&amp;quot;
    unlock_method  = &amp;quot;POST&amp;quot;
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then point &lt;code&gt;tfstate_host&lt;/code&gt; at the &lt;strong&gt;very server your managing with this Tofu config&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Now run a plan that takes down that box’s state server or proxy.&lt;/p&gt;

&lt;hr&gt;

&lt;h3 id=&#34;what-happens-you-might-ask&#34;&gt;What happens, you might ask?&lt;/h3&gt;

&lt;p&gt;Tofu cheerfully builds the plan. You don’t think twice. You type:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;tofu apply bad-idea.tfplan
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;It starts applying… and then suddenly throws a big red error:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🚨 &amp;ldquo;Failed to update state&amp;rdquo;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Well duh. The HTTP state server is gone. So now you’re in a half-broken world — the proxy’s down, random containers might be gone, and your infra is in limbo.&lt;/p&gt;

&lt;hr&gt;

&lt;h3 id=&#34;what-do-you-do-to-fix-it&#34;&gt;What do you do to fix it?&lt;/h3&gt;

&lt;p&gt;Here’s what I ended up doing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spun up a new &lt;a href=&#34;https://github.com/Clivern/Lynx&#34; target=&#34;_blank&#34;&gt;lynx&lt;/a&gt; instance manually:&lt;/li&gt;
&lt;/ol&gt;

&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;   docker run -d -p 4000:4000 --name tmp-lynx clivern/lynx:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;(There is more to this command, but basically you can convert your .tf definition to a docker run command, adding exposing port 4000.)&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Connected it to the right Docker network so it could talk to my database:&lt;/li&gt;
&lt;/ol&gt;

&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;   docker network connect --alias lynx network-lynx tmp-lynx
&lt;/code&gt;&lt;/pre&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Updated my &lt;code&gt;tfstate_host&lt;/code&gt; variable to point to &lt;code&gt;localhost:4000&lt;/code&gt;&lt;/p&gt;&lt;/li&gt;

&lt;li&gt;&lt;p&gt;Unlocked the state manually:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;   curl -u username:password -X POST http://localhost:4000/client/team/docker/prod/unlock
&lt;/code&gt;&lt;/pre&gt;

&lt;ol&gt;
&lt;li&gt;Reconfigured Tofu:&lt;/li&gt;
&lt;/ol&gt;

&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;   tofu init -reconfigure
&lt;/code&gt;&lt;/pre&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Ran a fresh plan, crossed my fingers that it would recreate the missing containers.&lt;/p&gt;&lt;/li&gt;

&lt;li&gt;&lt;p&gt;Applied the plan and verified everything came back, especially the Lynx container.&lt;/p&gt;&lt;/li&gt;

&lt;li&gt;&lt;p&gt;Cleaned up the temp Lynx:&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;pre&gt;&lt;code class=&#34;language-bash&#34;&gt;   docker stop tmp-lynx &amp;amp;&amp;amp; docker rm tmp-lynx
&lt;/code&gt;&lt;/pre&gt;

&lt;hr&gt;

&lt;p&gt;I also added &lt;code&gt;lifecycle&lt;/code&gt; rules to my Lynx container in Tofu to prevent deletion:&lt;/p&gt;

&lt;pre&gt;&lt;code class=&#34;language-hcl&#34;&gt;lifecycle {
  prevent_destroy = true
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And I wrote a little shell script that handles spinning up a temp Lynx container in case I ever do this again.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &amp;ldquo;right&amp;rdquo; answer is probably to move Lynx out of Tofu entirely&amp;hellip; but that’s a problem for another day.&lt;/strong&gt;&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Recommendation: Mealie</title>
    <link>https://connermccall.com/blog/2024/03/25/mealie-for-recipes/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/03/25/mealie-for-recipes/</guid>
    <description>How I use Mealie as a recipe manager and meal planner</description>
    <pubDate>Mon, 25 Mar 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I used an Android application called Cinnamon for years to manage my grocery shopping. It was very unstructured, allowed me to put items in different categories like &amp;ldquo;produce&amp;rdquo;, &amp;ldquo;meat counter&amp;rdquo;, &amp;ldquo;deli&amp;rdquo;, and mostly just stayed out of my way. It provided just enough help to make it more useful than pen and paper. Sadly, the author stopped updating it, and when I moved to a new phone, I couldn&amp;rsquo;t transfer it over without hoop jumping.&lt;/p&gt;

&lt;p&gt;I tried Home Assistant&amp;rsquo;s task feature, tried using a plain text list, and even contemplated building my own, but none of those really worked for me.&lt;/p&gt;

&lt;p&gt;At the same time, I was struggling a bit with recipes. I love cooking, and love trying new recipes, but I found myself thinking back to recipes I had tried from my various cookbooks and magazines and being unable to recall which ones we really loved. I needed a better way to manage recipe recall.&lt;/p&gt;

&lt;p&gt;Obviously, this could all be solved with a pencil and paper or a text file. But I know myself, that wouldn&amp;rsquo;t be a long-term strategy. So I did some research to see if there was a recipe application I could host myself.&lt;/p&gt;

&lt;p&gt;My research led me to &lt;a href=&#34;https://docs.mealie.io/&#34; target=&#34;_blank&#34;&gt;Mealie&lt;/a&gt;, a self-hosted and open source (AGPL) recipe manager.&lt;/p&gt;

&lt;p&gt;Here is a bit about how I use Mealie.&lt;/p&gt;

&lt;h3 id=&#34;recipe-imports&#34;&gt;Recipe Imports&lt;/h3&gt;

&lt;p&gt;&lt;img alt=&#34;Scraping Recipe Image&#34; src=&#34;/blog/2024/03/images/scraping-recipe.png&#34; /&gt;&lt;/p&gt;

&lt;p&gt;I subscribe to a couple of magazines, and tend to get a lot of inspiration from them. Before, I would find a recipe, set the magazine aside, cook the recipe a few weeks later, then file away the magazine. Weeks or months later, I would have to dig through my entire collection to figure out which issue the recipe I made came from. Sometimes I never could find it.&lt;/p&gt;

&lt;p&gt;With Mealie, when I find a recipe I want to try, I flag it, usually with some painters tape. Then later, I go through the magazine at my computer, open Mealie, find the online version of the recipe, and import it. Mealie grabs the ingredients, instructions, and description and creates a new recipe. Then I can add any additional information. I usually tag the recipe with the magazine I found it in and add a note for the issue and page. So I get the joy of enjoying a physical magazine and finding things to try, but then digitize it for easy retrieval.&lt;/p&gt;

&lt;p&gt;I do the same thing for recipes I might discover when browsing the web. I have also started adding recipes from cookbooks I own that I consider go-to options.&lt;/p&gt;

&lt;h3 id=&#34;tracking-new-recipes&#34;&gt;Tracking New Recipes&lt;/h3&gt;

&lt;p&gt;&lt;img alt=&#34;Tag collection for Items to try&#34; src=&#34;/blog/2024/03/images/to-try-tag.png&#34; /&gt;&lt;/p&gt;

&lt;p&gt;There are always new recipes I want to try. I used to either keep a stack of magazines somewhere or try to just remember that I saw something. Now, I just tag anything I have not made yet with &lt;code&gt;#totry&lt;/code&gt;. I have a cookbook that only shows me those recipes. So when I want to try a new recipe, I can find something quickly. Once I make it, I not only remove that tag, I can track when I made it and rate it. Long term, this will be super helpful for letting me return to recipes we love and sharing those with friends and family.&lt;/p&gt;

&lt;h3 id=&#34;meal-planning&#34;&gt;Meal Planning&lt;/h3&gt;

&lt;p&gt;&lt;img alt=&#34;Meal planning screenshot&#34; src=&#34;/blog/2024/03/images/meal-plan.png&#34; /&gt;&lt;/p&gt;

&lt;p&gt;Ever since Covid Quarantine, my spouse and I go shopping together once a week. This is a departure from pre-covid when we would both shop on our own and I would often shop multiple times per week. Having the good fortune of both working from home has kept us shopping together. This change means that we usually have a meal plan prepared before we go shopping. I wish I had had something like Mealie early in this transition, because I have found that being able to go into the app, add things I want to cook to specific days to be much better than my old notebook solution. I just add a recipe to the plan and after confirming with my spouse that we are not making similar things, I can build my shopping list using the recipes. I can add all the ingredients from the recipe, excluding those I know we already have on hand.&lt;/p&gt;

&lt;h2 id=&#34;a-couple-of-hacks&#34;&gt;A couple of &amp;ldquo;Hacks&amp;rdquo;&lt;/h2&gt;

&lt;h4 id=&#34;placeholder-recipes&#34;&gt;Placeholder Recipes&lt;/h4&gt;

&lt;p&gt;I have a subset of meals that I use as a base and then relying on what we have in the fridge or what looks good at the store, the garden, or farmers market.&lt;/p&gt;

&lt;p&gt;One example of this is pizza. I love making pizza, but I rarely know what kind when planning. So in those case, I can just make a placeholder meal to throw on my plan. I sometimes add the &amp;ldquo;core&amp;rdquo; ingredients that I almost always purchase to the recipe. For The Pizza recipe, I only have Cheese in that section since I rarely buy sauce or crust and tend to choose my toppings in the moment.&lt;/p&gt;

&lt;p&gt;&lt;img alt=&#34;Screenshot of the pizza placeholder&#34; src=&#34;/blog/2024/03/images/pizza-placeholder.png&#34; /&gt;&lt;/p&gt;

&lt;h4 id=&#34;pantry-label&#34;&gt;Pantry Label&lt;/h4&gt;

&lt;p&gt;There are things like olive oil, salt, soy sauce, etc., that we almost always have on hand. I label these as Pantry, and when I am building my shopping list, if I am not 100% sure we have enough for the recipe, I can add them to the list and &amp;ldquo;hopefully&amp;rdquo; check before we head off to the store.&lt;/p&gt;

&lt;p&gt;Finally, because why not, I have integrated Mealie with this site. So I can now select a recipe to share and when the site publishes, it gets a page on my site. This is not only fun for blog purposes but is also a nice way for me to share recipes with others without needing to publicly expose my recipe site to the public Internet. I actually &lt;a href=&#34;/2024/03/07/external-data-in-astro/&#34;&gt;wrote a post&lt;/a&gt; about this already if you want to know some of the technical details. A simple recipe I am sharing this way is this &lt;a href=&#34;https://connermccall.com/recipes/pork-and-beans-pasta/&#34; target=&#34;_blank&#34;&gt;super easy and delicious pasta&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I hope you give Mealie a try, the author is working on &lt;a href=&#34;https://recipinned.com/&#34; target=&#34;_blank&#34;&gt;launching a paid version&lt;/a&gt; that might be a great option if you are not someone who wants to host it themselves.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Integrating Webmentions with Astro</title>
    <link>https://connermccall.com/blog/2024/03/18/webmentions-with-astro/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/03/18/webmentions-with-astro/</guid>
    <description>How I integrated webmentions with Astro and webmentiond</description>
    <pubDate>Mon, 18 Mar 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I spent a bit of time over the past week and weekend learning a bit more about &lt;a href=&#34;/blog/2024/02/27/joining-indie-web/&#34;&gt;webmentions&lt;/a&gt;. I finally learned enough to get them enabled on this site. You can see the first mention displayed on &lt;a href=&#34;/blog/2024/03/13/stop-saying-ai/&#34;&gt;my ai naming post&lt;/a&gt;. I thought I would write up a post showing how I got mentions working.&lt;/p&gt;

&lt;h2 id=&#34;receiving&#34;&gt;Receiving&lt;/h2&gt;

&lt;p&gt;Initially, I had plans of writing my own webmention receiver, but after some consideration I decided that using an open source tool would be faster. I did not find a lot of options, and I settled on installing &lt;a href=&#34;https://webmentiond.org&#34; target=&#34;_blank&#34;&gt;webmentiond&lt;/a&gt;. This seems to be a solid implementation that is written in Go and can be fairly easily hosted using Docker which met my requirements. I have yet to run into any missing features I care about, but knowing I can learn some Go while extending it is a plus for me.&lt;/p&gt;

&lt;p&gt;After getting this up and running, I was able to successfully start receiving webmentions.&lt;/p&gt;

&lt;h2 id=&#34;displaying&#34;&gt;Displaying&lt;/h2&gt;

&lt;p&gt;Webmentiond provides a small Javascript snippet that can fetch webmentions from your server based on a page&amp;rsquo;s url. This is basically a JS widget. Since one of my goals for this site is not running any required javascript, this approach did not work for me. I ended up adding functionaity to my Astro site to include the mentions at build time.&lt;/p&gt;

&lt;h2 id=&#34;ci-authentication&#34;&gt;CI Authentication&lt;/h2&gt;

&lt;p&gt;Happily webmentiond provides a simple api that we can use to fetch all mentions for your site with 2 requests. The first request uses what is technically a preshared key to generate a access token. This key should be generated by you, stored security, and added to the startup uption for webmentiond. This looks like:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--auth-admin-access-keys YOUR_GENERATED_KEY=ci&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Note that the &lt;code&gt;=ci&lt;/code&gt; portion is just an identifier, it can be anything, but ci works well since the main use is to integrate this into a Drone build process.&lt;/p&gt;

&lt;h2 id=&#34;fetching-your-mentions&#34;&gt;Fetching your mentions&lt;/h2&gt;

&lt;p&gt;Now that we have a preshared key, you will want to add two new &lt;code&gt;ENV&lt;/code&gt; variables. For development purposes I just added these to a &lt;code&gt;.env&lt;/code&gt; file in the root of my astro projects.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;PUBLIC_WEBMENTIOND_TOKEN = The url that is used to access your webmentiond instance 
PUBLIC_WEBMENTIOND_URL = YOUR_GENERATED_KEY
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Then we can fetch the mentions. The code for this looks like*.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;let promise: Promise&amp;lt;Response&amp;gt;;
let fetching = false;
import type { Response } from &amp;quot;./webmentions&amp;quot;;
export default async function(approved=true) {
    if(!fetching) {
        fetching = true;
        
        promise = new Promise(async (resolve) =&amp;gt; {
            const mentions = await fetchMentions()
            if(approved) {
                resolve({items: mentions.items.filter((mention) =&amp;gt; {
                    return mention.status === &amp;quot;approved&amp;quot;
                })});
                return
            }
            
            resolve(mentions);
        })
    }
    return promise;
}

async function fetchJWT(){
    return await (await fetch(&#39;https://wm.connermccall.com/authenticate/access-key&#39;, {
        method: &amp;quot;POST&amp;quot;, // Using POST method
        headers: {&#39;Content-Type&#39;: &#39;application/x-www-form-urlencoded&#39;},
        body: \`key=\${import.meta.env.PUBLIC_WEBMENTIOND_TOKEN}\`,
    })).text();
}

async function fetchMentions(): Promise&amp;lt;Response&amp;gt;{
    const jwt = await fetchJWT();
    return await (await fetch(&#39;https://wm.connermccall.com/manage/mentions&#39;, {
        headers: {&#39;Authorization&#39;: \`Bearer \${jwt}\`},
    })).json()
}

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

&lt;p&gt;In this code you&amp;rsquo;ll notice I wrap the fetch in a Promise. This prevents the Fetch from running for every single post. This approach caches the first request and reuses the response for all future posts. The chain of requests is to first use the long lived token to fetch a JWT token, then use that JWT token to fetch the mentions.&lt;/p&gt;

&lt;p&gt;One thing you may want to do from a security standpoint on your webmentiond service is set &lt;code&gt;--auth-admin-access-key-jwt-ttl&lt;/code&gt; to something like 10s or 30s. Since we do not reuse this key it should expire almost immediatly after we use it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;*Technically I should have made these a &lt;code&gt;data&lt;/code&gt; content type in Astro&lt;/em&gt;&lt;/p&gt;

&lt;h2 id=&#34;displaying-mentions&#34;&gt;Displaying mentions&lt;/h2&gt;

&lt;p&gt;Now that we have a way to fetch our mentions, we need to actually display them. In this case I created a new &lt;code&gt;.astro&lt;/code&gt; file that exports a React component to fetch and display the mentions based on a slug.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;import fetch_webmentions from &amp;quot;../lib/fetch_webmentions&amp;quot;;
import Like from &#39;./mentions/Like.astro&#39;;
import Reply from &#39;./mentions/Reply.astro&#39;;
import Repost from &#39;./mentions/Repost.astro&#39;;
import Mention from &#39;./mentions/Mention.astro&#39;;
import Rsvp from &#39;./mentions/Rsvp.astro&#39;;
import Comment from &#39;./mentions/Comment.astro&#39;
interface Props {
	slug: string;
}

const { slug } = Astro.props;

const mentions = (await fetch_webmentions()).items.filter((mention) =&amp;gt; {
    return mention.target.indexOf(slug) &amp;gt; -1
});

---
&amp;lt;div class=&amp;quot;webmentions mx-auto mb-2 text-base&amp;quot;&amp;gt;
    {mentions.length &amp;gt; 0 &amp;amp;&amp;amp;  &amp;lt;h2 class=&amp;quot;text-lg&amp;quot;&amp;gt;Mentions&amp;lt;/h2&amp;gt;
 &amp;lt;ul class=&amp;quot;mb-4 text-base &amp;quot;&amp;gt;
    {mentions.map((mention)=&amp;gt; ( 
        &amp;lt;li class=&amp;quot;mb-4 border-b&amp;quot;&amp;gt;
            {mention.type &amp;amp;&amp;amp; mention.type === &#39;like&#39; &amp;amp;&amp;amp; &amp;lt;Like mention={mention} /&amp;gt;}
            {mention.type &amp;amp;&amp;amp; mention.type === &#39;reply&#39; &amp;amp;&amp;amp; &amp;lt;Reply mention={mention} /&amp;gt;}
            {mention.type &amp;amp;&amp;amp; mention.type === &#39;repost&#39; &amp;amp;&amp;amp; &amp;lt;Repost mention={mention} /&amp;gt;}
            {mention.type &amp;amp;&amp;amp; mention.type === &#39;rsvp&#39; &amp;amp;&amp;amp; &amp;lt;Rsvp mention={mention} /&amp;gt;}
            {mention.type &amp;amp;&amp;amp; mention.type === &#39;comment&#39; &amp;amp;&amp;amp; &amp;lt;Comment mention={mention} /&amp;gt;}
            {mention.type &amp;amp;&amp;amp; mention.type === &#39;mention&#39; &amp;amp;&amp;amp; &amp;lt;Mention mention={mention} /&amp;gt;}
            {mention.type === undefined &amp;amp;&amp;amp; &amp;lt;Mention mention={mention} /&amp;gt;}
        &amp;lt;/li&amp;gt;
    
    ))}
    &amp;lt;/ul&amp;gt;}
    &amp;lt;p&amp;gt;&amp;lt;a class=&amp;quot;text-sm text-gray-600&amp;quot; href=&amp;quot;/replying&amp;quot;&amp;gt;How to reply to this post&amp;lt;/a&amp;gt;&amp;lt;/p&amp;gt;
    &amp;lt;/div&amp;gt;

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

&lt;p&gt;Now in my &lt;code&gt;BLogPost.astro&lt;/code&gt; layout, I can just add
&lt;code&gt;&amp;lt;Mentions slug={slug} /&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;This component fetches the mentions, filters for the mentions with a target that matches the given slug, then displays the mention using a specific component based on the type. This makes it extremely easy to work with a single mention type, add a new type, or even remove a type all together. Based on my reading the six types I chose should be the most common types you would encounter.&lt;/p&gt;

&lt;p&gt;A lot of folks are using &lt;a href=&#34;https://webmention.io/&#34; target=&#34;_blank&#34;&gt;Webmention.io&lt;/a&gt; to handle receiving webmentions. If you go that route, you would need to modify &lt;code&gt;fetch_webmentions&lt;/code&gt; to use their endpoint for  &lt;a href=&#34;https://github.com/aaronpk/webmention.io?tab=readme-ov-file#find-all-links-to-your-domain&#34; target=&#34;_blank&#34;&gt;fetching all links on your domain&lt;/a&gt;. You&amp;rsquo;ll likely need to modify the rest of the code to match what the JSON format for the mentions looks like.&lt;/p&gt;

&lt;p&gt;You can see the code for my site, including the webmention pieces &lt;a href=&#34;https://github.com/sloped/astro-site&#34; target=&#34;_blank&#34;&gt;on github&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Enabling webmentions</title>
    <link>https://connermccall.com/blog/2024/03/15/webmentions-enabled/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/03/15/webmentions-enabled/</guid>
    <description>I got webmentions working</description>
    <pubDate>Fri, 15 Mar 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I finally finished one of the tasks I had remaining for &lt;a href=&#34;/blog/2024/02/27/joining-indie-web/&#34;&gt;joining the indie web&lt;/a&gt;. This site now support &lt;a href=&#34;/replying&#34;&gt;webmentions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I wrote a &lt;a href=&#34;2024/03/18/webmentions-with-astro&#34; target=&#34;_blank&#34;&gt;post&lt;/a&gt; with some details on how I implemented them.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>OTP on the CLI</title>
    <link>https://connermccall.com/blog/2024/03/14/ykman-otp-script/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/03/14/ykman-otp-script/</guid>
    <description>A small bash script I wrote to make generating otp codes from my Yubikey easy</description>
    <pubDate>Thu, 14 Mar 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I have used a Yubikey as the primary way of managing my OTP codes for several years now. I usually use the Yubico Authenticator application, which is great. But, sometimes when I am working in my editor and terminal I just want to stay there.&lt;/p&gt;

&lt;p&gt;So I wrote this little wrapper around &lt;code&gt;ykman&lt;/code&gt; to make my life a bit easier. It is very simplistic, but it gets the job done for me. To use, just make sure it is executable and in your path, then you can just type something like &lt;code&gt;otp github&lt;/code&gt; and it will stick the code into your clipboard, assuming you have &lt;code&gt;xclip&lt;/code&gt; installed.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;#!/bin/bash
# alias pbcopy=&amp;quot;xclip -sel clip&amp;quot;
if [ -z &amp;quot;$1&amp;quot; ]
then
   echo &#39;need account name &#39;
   exit 1
else
    CODE=$(ykman oath accounts code $1 | awk &#39;{print $NF}&#39;)
fi
echo $CODE | xclip -sel cli 
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The only error checking this does is check to ensure you pass an account name. If you have multiple accounts that match your account name, things will be weird. I also do not think this will handle codes that require touch.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Stop Saying AI</title>
    <link>https://connermccall.com/blog/2024/03/13/stop-saying-ai/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/03/13/stop-saying-ai/</guid>
    <description>They are Large Language Models, not Intelligence</description>
    <pubDate>Wed, 13 Mar 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;In a conversation with a colleague today I realized I have decided to no longer refer to LLMs as AI. I guess this is my tiny rebellion against the ongoing hype.&lt;/p&gt;

&lt;p&gt;I think LLMs are amazing and have made work that I used to really dislike a lot less frustrating. You can probably tell I use DALL-E to generate some of the hero images on this site. I use one to help me proofread and edit things quite often. They are going to have negative impacts on the number of jobs in some industries. But they are not intelligence and anyone who spends time with them quickly realizes they are just a very superpowered suggestion engine. AI gives the idea of them way more power than they deserve, so let&amp;rsquo;s stop calling them that.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Adding External data in Astro</title>
    <link>https://connermccall.com/blog/2024/03/07/external-data-in-astro/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/03/07/external-data-in-astro/</guid>
    <description>How to bring external data into Astro</description>
    <pubDate>Thu, 07 Mar 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I am working on a longer post about how I have started using &lt;a href=&#34;https://docs.mealie.io/&#34; target=&#34;_blank&#34;&gt;Mealie&lt;/a&gt; to organize my recipes and manage my grocery list. In the middle of that, I decided I wanted to have an easy way to share recipes from Mealie. I currently block external traffic to Mealie, so the &amp;ldquo;share&amp;rdquo; feature that is built into it does not work for me. I could open it to public traffic, but I am working toward a zero trust policy on my network so that would go against that philosophy.&lt;/p&gt;

&lt;p&gt;I built this site using &lt;a href=&#34;https://astro.build&#34; target=&#34;_blank&#34;&gt;Astro&lt;/a&gt; so I had flexibility, and I realized I could fetch recipe data from the Mealie API. So, what if I used Astro to fetch the data and used that to generate my recipe content? Turns out you can, I wired things up, and I can now easily choose recipes from Mealie I want to share, add a bit of context, and when my site deploys it includes the recipe details. The nice thing is that if I tweak a recipe, those tweaks show up the next time I publish content, without any effort on my part.&lt;/p&gt;

&lt;p&gt;You can use this same method to fetch data from pretty much any source to add external data to a static site built with Astro.&lt;/p&gt;

&lt;p&gt;Here is how I integrated Mealie into my Astro site.&lt;/p&gt;

&lt;p&gt;First, I created a content collection. Add this to &lt;code&gt;src/content/config.ts&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Technically you could use the external data directly, but having a collection gives more control than just fetching.&lt;/em&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const recipes = defineCollection({
	type: &#39;content&#39;,
	schema: ({image}) =&amp;gt; z.object(
		// Using this so that I do not need to build fetching of images into the process. It is an extra manual step, but then we can take advantage of Asto&#39;s image handling. 
		heroImage: image().refine((img) =&amp;gt; img.width &amp;gt;= 1080, {
			message: &amp;quot;Cover image must be at least 1080 pixels wide!&amp;quot;,
		  }).optional(),
		// This is the slug for the recipe in Mealie
		recipe_slug: z.string(),
		pubDate: z.coerce.date(),
		is_draft: z.boolean().optional().default(false)
	}),
})
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In this code the &lt;code&gt;heroImage&lt;/code&gt; is an image I store in the content directory of my site. and &lt;code&gt;recipe_slug&lt;/code&gt; refers to the slug of the recipe in my Mealie site.&lt;/p&gt;

&lt;p&gt;Next, you will want to create two new pages &lt;code&gt;src/pages/recipes.[slug].astro&lt;/code&gt; and &lt;code&gt;src/pages/recipes/index.astro&lt;/code&gt;. &lt;em&gt;You can skip the &lt;code&gt;index&lt;/code&gt; page if you do not want a listing page.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;[slug].astro&lt;/code&gt;, you need to export a &lt;code&gt;getStaticPaths&lt;/code&gt; function that will first fetch the ContentCollection, then fetch the recipes from Mealie and augment the data. &lt;em&gt;If you are only using the external data, skip the &lt;code&gt;getCollection&lt;/code&gt; call and just fetch your data.&lt;/em&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;export async function getStaticPaths() {
	// fetch all the recipes async. If the number of recipes grows we may need to rate limit this.
	const merged = await Promise.all((await getCollection(&#39;recipes&#39;)).map(async (recipe) =&amp;gt; {
		// we just brute force the data together, but you could cherry-pick your data. I also ensured recipe is last so I could override values from Mealie if wanted.
	const from_mealie = await fetchRecipe(recipe.data.recipe_slug);
		return Object.assign({}, from_mealie, recipe)
	}))
	// sort by the blog publish date
	const recipes = merged.sort(
		(b, a) =&amp;gt; a.data.pubDate.valueOf() - b.data.pubDate.valueOf()
	).filter((recipe) =&amp;gt; {
		return recipe.data.is_draft !== true
	});
	// this is tells Astro how to build the paths for the pages. 
	return recipes.map( (recipe) =&amp;gt; ({
		params: { slug: recipe.data.recipe_slug },
		props: recipe,
	}));
	
	async function fetchRecipe(slug: string) {
		const response = await fetch(\`https://yourmealieurl/api/recipes/\${slug}\`, {
		method: &#39;GET&#39;,
		headers: {
			&#39;Authorization&#39;: \`Bearer \${import.meta.env.PUBLIC_MEALIE_API_KEY} \`
		}
	})

		const recipe = await response.json();
		return recipe
    }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;This code gets all the &lt;code&gt;.md&lt;/code&gt; files under &lt;code&gt;/src/content/recipes&lt;/code&gt;, then for each recipe we fetch the data from Mealie with the slug. Once fetched we merge the data from the content collection with the data from the Mealie API. Then we pass the merged object to a Recipe Component.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;type Props = RecipeType;

const recipe = Astro.props;
const { Content } = await recipe.render();
---

&amp;lt;Recipe {...recipe}&amp;gt;
	&amp;lt;Content /&amp;gt;
&amp;lt;/Recipe&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;And my Recipe component.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;
---
import BaseHead from &#39;../components/BaseHead.astro&#39;;
import Header from &#39;../components/Header.astro&#39;;
import Footer from &#39;../components/Footer.astro&#39;;
import FormattedDate from &#39;../components/FormattedDate.astro&#39;;
import Ingredient from &#39;../components/Ingredient.astro&#39;;
import { SITE_TITLE } from &#39;../consts&#39;;
import Instruction from &#39;../components/Instruction.astro&#39;;
import { Image } from &#39;astro:assets&#39;;
import type RecipeType from &#39;../lib/content/recipes&#39;

type Props = RecipeType;
const { data, name, updateAt, description, recipeYield, recipeIngredient, recipeInstructions} = Astro.props;
---
&amp;lt;html lang=&amp;quot;en&amp;quot;&amp;gt;
	&amp;lt;head&amp;gt;
		&amp;lt;BaseHead title={\`\${name} | \${SITE_TITLE}\`} description={description} /&amp;gt;
		&amp;lt;link rel=&amp;quot;author&amp;quot; href=&amp;quot;https://connermccall.com&amp;quot; /&amp;gt;
		&amp;lt;/style&amp;gt;
	&amp;lt;/head&amp;gt;

	&amp;lt;body&amp;gt;
		&amp;lt;Header /&amp;gt;
		&amp;lt;main class=&amp;quot;full h-entry&amp;quot;&amp;gt;
			&amp;lt;article&amp;gt;
				
				&amp;lt;div class=&amp;quot;flex hero-image pb-4 justify-center&amp;quot;&amp;gt;
					{data.heroImage &amp;amp;&amp;amp; &amp;lt;Image src={data.heroImage} alt=&amp;quot;&amp;quot; /&amp;gt;}
				&amp;lt;/div&amp;gt;
				&amp;lt;div&amp;gt;
					&amp;lt;div class=&amp;quot;title max-w-prose mx-auto&amp;quot;&amp;gt;
						&amp;lt;div class=&amp;quot;date text-center text-lg&amp;quot;&amp;gt;
							&amp;lt;FormattedDate date={data.pubDate} /&amp;gt;
							{
								updateAt &amp;amp;&amp;amp; (
									&amp;lt;div class=&amp;quot;last-updated-on dt-published&amp;quot;&amp;gt;
										Last updated on &amp;lt;FormattedDate date={new Date(updateAt)} /&amp;gt;
									&amp;lt;/div&amp;gt;
								)
							}
						&amp;lt;/div&amp;gt;
						&amp;lt;h1 class=&amp;quot;center py-4 text-center p-name&amp;quot;&amp;gt;&amp;lt;a class=&amp;quot;u-url text-black hover:text-black&amp;quot; href={\`/blog/\${Astro.props.slug}/\`}&amp;gt;{name}&amp;lt;/a&amp;gt;&amp;lt;/h1&amp;gt;
						&amp;lt;hr class=&amp;quot;pb-4&amp;quot;/&amp;gt;
					&amp;lt;/div&amp;gt;
					&amp;lt;div class=&amp;quot;prose mx-auto e-content&amp;quot;&amp;gt;
						&amp;lt;slot /&amp;gt;
						&amp;lt;h2&amp;gt;Description&amp;lt;/h2&amp;gt;
                        &amp;lt;p&amp;gt;{description}&amp;lt;/p&amp;gt;
                        &amp;lt;h2&amp;gt;Makes&amp;lt;/h2&amp;gt;
						&amp;lt;p&amp;gt;{recipeYield}&amp;lt;/p&amp;gt;
                        &amp;lt;div&amp;gt;
                            &amp;lt;h2&amp;gt;Ingredients&amp;lt;/h2&amp;gt;
                            &amp;lt;ul class=&amp;quot; bg-gray-100 py-3  &amp;quot;&amp;gt;
                                
                            {
                                recipeIngredient.map((ingredient) =&amp;gt; (
                                &amp;lt;li&amp;gt;&amp;lt;Ingredient ingredient={ingredient} /&amp;gt;&amp;lt;/li&amp;gt;
                                ))
                            }
                            &amp;lt;/ul&amp;gt;
                        &amp;lt;/div&amp;gt;
                        &amp;lt;div&amp;gt;
                            &amp;lt;h2&amp;gt;Instructions&amp;lt;/h2&amp;gt;
                                {
                                    recipeInstructions.map((instruction) =&amp;gt; (
                                    &amp;lt;Instruction instruction={instruction} /&amp;gt;
                                    ))
                                }
                                
                        &amp;lt;/div&amp;gt;
				    &amp;lt;/div&amp;gt;
				&amp;lt;/div&amp;gt;
			&amp;lt;/article&amp;gt;
		&amp;lt;/main&amp;gt;
		&amp;lt;Footer /&amp;gt;
	&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;

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

&lt;p&gt;You do almost the same thing in &lt;code&gt;index.astro&lt;/code&gt;. Fetch the data, merge the properties, then you can just loop through the recipe and show the details you want on your landing page.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;const merged = await Promise.all((await getCollection(&#39;recipes&#39;)).map(async (recipe) =&amp;gt; {
	const from_mealie = await fetchRecipe(recipe.data.recipe_slug);
	return Object.assign({}, from_mealie, recipe.data)
}))
const recipes = merged.sort(
	(b, a) =&amp;gt; a.pubDate.valueOf() - b.pubDate.valueOf()
).filter((recipe) =&amp;gt; {
	return recipe.is_draft !== true
});
...
{
	recipes.map((data) =&amp;gt; (
		
	&amp;lt;li class=&amp;quot; max-w-[444px] md:max-w-none&amp;quot;&amp;gt;
			&amp;lt;a href={\`/recipes/\${data.slug}/\`}&amp;gt;
				&amp;lt;Image alt=&amp;quot;&amp;quot; height=&amp;quot;300&amp;quot; src={data.heroImage} /&amp;gt;
				&amp;lt;h4 class=&amp;quot;title text-xl pt-4&amp;quot;&amp;gt;{data.name}&amp;lt;/h4&amp;gt;
				&amp;lt;p class=&amp;quot;date text-sm text-gray-600&amp;quot;&amp;gt;
					&amp;lt;FormattedDate date={data.pubDate} /&amp;gt;
				&amp;lt;/p&amp;gt;
			&amp;lt;/a&amp;gt;
		&amp;lt;/li&amp;gt;
	
	))
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I explored an alternative approach where I tagged recipes in Mealie with a &amp;lsquo;blog&amp;rsquo; tag and then queried for all recipes with that tag. Doing this I could avoid the need to use a Content Collection and only use data from the api. I decided that using a Content Collection gave me the benefit of ensuring that this codebase controlled what recipes appeared, I could add additional context to the recipe that I do not need in Mealie, and I can control the image show alongside the recipe while taking advantage of Astro&amp;rsquo;s image processing.&lt;/p&gt;

&lt;p&gt;Look for my post about Mealie in a few days.&lt;/p&gt;

&lt;p&gt;You can see the code in context in my demo &lt;a href=&#34;https://github.com/sloped/astro-site&#34; target=&#34;_blank&#34;&gt;github repository&lt;/a&gt;.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Masked email has become my Fastmail killer feature</title>
    <link>https://connermccall.com/blog/2024/02/29/masked-email/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/02/29/masked-email/</guid>
    <description>How I use masked emails to improve privacy and security</description>
    <pubDate>Thu, 29 Feb 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I migrated my email away from Gmail a little over 10 years ago. I gave running my own email server a go. It went okay, but within a year, I got sick of dealing with upgrades, undelivered mail, and my mail being marked as spam. So, I made the switch to &lt;a href=&#34;https://fastmail.com&#34; target=&#34;_blank&#34;&gt;FastMail&lt;/a&gt;. My first invoice was in December of 2014. I have been a satisfied customer ever since.&lt;/p&gt;

&lt;p&gt;FastMail is always reliable, has a &amp;ldquo;good enough&amp;rdquo; interface, and costs less than a dollar a day. They do not introduce a ton of features, which I actually consider a feature. But a while ago, they added Masked Emails to their feature list. This is a killer feature for me.&lt;/p&gt;

&lt;p&gt;Masked Emails are not a new feature. Apple introduced Hide My Email in iOS 15, and I am sure it existed before Apple. The main idea is that you can create a new and unique email address to give to anyone at any time. That new email address is directed to your main Inbox just like your primary emails.&lt;/p&gt;

&lt;p&gt;What makes FastMail&amp;rsquo;s version interesting to me is that they allow you to bring your own domain. Their default version generates emails with a fastmail owned domain. But with a bit of effort, you can use any domain you own. So, for example, on this site, I have the email address &lt;code&gt;gritty.frog3468@cafm.me&lt;/code&gt; listed as my &amp;ldquo;indieweb&amp;rdquo; email. This is the only place I plan on using that email. If someone emails me at that address, it goes to my main Inbox and is labeled however I want, in this case, &amp;ldquo;rel-me&amp;rdquo;. If that address starts accumulating spam, it is easy enough to swap it out with a new address.&lt;/p&gt;

&lt;p&gt;&lt;img alt=&#34;gritty.frog3468 email in the masked email interface of fastmail&#34; src=&#34;/blog/2024/02/images/gritty.frog.png&#34; /&gt;&lt;/p&gt;

&lt;p&gt;Since I started using Masked Emails, I use them almost everywhere. Anytime I sign up for a new service, I use a new masked email, and often when updating accounts, I will change my old email address to a new masked email. I even use masked emails when setting up services I host myself.&lt;/p&gt;

&lt;p&gt;There are multiple ways to generate a masked email. I use three:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can use FastMail&amp;rsquo;s interface; it is a bit of a chore, though easier on mobile.&lt;/li&gt;
&lt;li&gt;I use 1Password, and they have an integration and can generate an email and store it in the login automatically, which is slick.&lt;/li&gt;
&lt;li&gt;Use the API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The API use case is something I started using because I was sick of trying to tell people my email address in person. I use either an address which includes my full name, or a short email with the &lt;code&gt;.cafm.me&lt;/code&gt; domain. I have found that if you do not use Gmail, Yahoo, or Apple, people tend to be confused.
The other problem is that when I had to give an email out in person, it was never a masked email, defeating the purpose of using masked emails.&lt;/p&gt;

&lt;p&gt;To solve this, I built a little website. It allows me to put in a description, click generate, and get a masked email. This is much faster than the FastMail interface, plus I have the email displayed at a size large enough that I can just show someone my phone instead of telling them the address. It makes their life easier and streamlines mine.
&lt;img alt=&#34;Form for masked email application&#34; src=&#34;/blog/2024/02/images/api-form.png&#34; /&gt;
&lt;img alt=&#34;Output of  masked email application&#34; src=&#34;/blog/2024/02/images/api-generated.png&#34; /&gt;&lt;img alt=&#34;New address in Fastmail&#39;s interface&#34; src=&#34;/blog/2024/02/images/api-in-interface.png&#34; /&gt;&lt;/p&gt;

&lt;p&gt;There are several benefits to using masked emails. Here are the three I enjoy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You know when someone has sold, given away, or had your email address stolen. If you give your email to your local medical provider and a year later get emails from another party with that address, you know they are the root cause and can decide what to do about it.&lt;/li&gt;
&lt;li&gt;If the unsubscribe button does not work, you can just turn off that masked email, either block it and send them directly to trash, or delete it and have it bounce.&lt;/li&gt;
&lt;li&gt;Annoying form validation that might have blocked + addresses have no way of rejecting your masked email since it is no different from other email addresses.&lt;/li&gt;
&lt;li&gt;Email addresses are short and sometimes hilarious.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You could do a lot more with them with filters and rules, but I tend to keep my email flow simple.&lt;/p&gt;

&lt;p&gt;If the Fastmail team is reading this, I do have one request, allow me to quickly manage the masked email of the message I have opened in the web interface. Having a block or delete feature right there would be super useful when I get a new email from a newsletter I have unsubscribed from.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Fixing Broken Images in Mastodon</title>
    <link>https://connermccall.com/blog/2024/02/28/mastodon-images/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/02/28/mastodon-images/</guid>
    <description>How to properly setup volumes in Mastodon with Docker</description>
    <pubDate>Wed, 28 Feb 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;In my post about joining the &lt;a href=&#34;/blog/2024/02/27/joining-indie-web/&#34;&gt;small web&lt;/a&gt;, I discussed integrating this site with the IndieWeb network. Another community I&amp;rsquo;ve been eager to join is the &lt;a href=&#34;https://en.wikipedia.org/wiki/Fediverse&#34; target=&#34;_blank&#34;&gt;Fediverse&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;I went ahead and added Mastodon to my services and set up a personal network. I found a few users to follow, but encountered a persistent issue: images from posts outside my server displayed only as &amp;lsquo;placeholders&amp;rsquo;.&lt;/p&gt;

&lt;p&gt;A temporary fix was to execute &lt;code&gt;tootctl media refresh --days=2&lt;/code&gt;, which worked for existing posts but not for new ones. I read through forums and issues trying to find a similar issue, but anything that looked the same ended up being slightly different. My frustration was growing and honestly without images the experience of Mastodon was poor enough I might have just scrapped the whole idea.&lt;/p&gt;

&lt;p&gt;Then it hit me(while in the shower, where all great ideas are formed), I recalled an initial setup error related to &lt;code&gt;/opt/mastodon/public/system/cache&lt;/code&gt; permissions. The resolution was mounting this directory as a volume not only for the Mastodon web instance but also for the sidekiq container.&lt;/p&gt;

&lt;p&gt;Here&amp;rsquo;s how the volumes are now configured:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;volumes {
	container_path = &amp;quot;/mastodon/public/system&amp;quot;
	host_path = &amp;quot;/var/docker-service/mastodon/public/system&amp;quot;
}

volumes {
	container_path = &amp;quot;/opt/mastodon/public/system/cache&amp;quot;
	host_path = &amp;quot;/var/docker-service/mastodon/cache&amp;quot;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Curiously, there are two distinct system and cache directories. The official &lt;a href=&#34;https://github.com/mastodon/mastodon/blob/main/docker-compose.yml&#34; target=&#34;_blank&#34;&gt;docker-compose.yml&lt;/a&gt; only references &lt;code&gt;/mastodon/public/system&lt;/code&gt;. I need to investigate more to determine whether this was an error on my part or an issue worth reporting.&lt;/p&gt;

&lt;p&gt;Hopefully, this post aids another soul looking to venture into the Fediverse.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Joining the Indieweb</title>
    <link>https://connermccall.com/blog/2024/02/27/joining-indie-web/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/02/27/joining-indie-web/</guid>
    <description>The steps I took to join the indie web</description>
    <pubDate>Tue, 27 Feb 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I started this blog for a couple of key reasons: to share my thoughts and to become part of the &amp;ldquo;small web.&amp;rdquo; Writing my first post ticked the first box instantly. The second goal is more of a journey. Lately, my focus has shifted towards integrating my site with the &lt;a href=&#34;https://indieweb.org/&#34; target=&#34;_blank&#34;&gt;Indie Web&lt;/a&gt;, a concept that&amp;rsquo;s easier said than done, as solid resources are scarce. However, I&amp;rsquo;m making headway through the steps on &lt;a href=&#34;https://indiewebify.me&#34; target=&#34;_blank&#34;&gt;Indiewebify.me&lt;/a&gt;.&lt;/p&gt;

&lt;h2 id=&#34;steps&#34;&gt;Steps&lt;/h2&gt;

&lt;p&gt;Here&amp;rsquo;s what I&amp;rsquo;m tackling, based on the Indie Web site&amp;rsquo;s guidance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ✅ ] Purchase a domain name.

&lt;ul&gt;
&lt;li&gt;I have owned this domain for 16 years and see no reason to change.&lt;/li&gt;
&lt;/ul&gt;&lt;/li&gt;
&lt;li&gt;[ ✅ ] Publish content to your domain.

&lt;ul&gt;
&lt;li&gt;I have a webserver and publish my content with &lt;a href=&#34;https://astro.build&#34; target=&#34;_blank&#34;&gt;Astro&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;&lt;/li&gt;
&lt;li&gt;[ ✅ ] Setup web sign in.

&lt;ul&gt;
&lt;li&gt;This requires adding &lt;code&gt;rel=me&lt;/code&gt; links to your homepage. In my case I added links to my profiles with Github, Twitter, and Threads. I also added a masked email address.&lt;/li&gt;
&lt;/ul&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;pre&gt;&lt;code&gt;		&amp;lt;link rel=&amp;quot;me&amp;quot; href=&amp;quot;https://github.com/sloped&amp;quot; /&amp;gt;
		&amp;lt;link rel=&amp;quot;me&amp;quot; href=&amp;quot;https://twitter.com/sloped&amp;quot; /&amp;gt;
		&amp;lt;link rel=&amp;quot;me&amp;quot; href=&amp;quot;mailto:gritty.frog3468@cafm.me&amp;quot; /&amp;gt;
		&amp;lt;link rel=&amp;quot;me&amp;quot; href=&amp;quot;https://www.threads.net/@sloped&amp;quot; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;ul&gt;
&lt;li&gt;[ ✅ ] Add h-card

&lt;ul&gt;
&lt;li&gt;This requires some html modifications. I added the &lt;code&gt;h-card&lt;/code&gt; class to my homepage, along with &lt;code&gt;p-name&lt;/code&gt;, &lt;code&gt;p-category&lt;/code&gt;, and some other identifiers. You can see this at &lt;a href=&#34;https://indiewebify.me/validate-h-card/?url=http://connermccall.com&#34; target=&#34;_blank&#34;&gt;https://indiewebify.me/validate-h-card/?url=http://connermccall.com&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;One key is that you do not need to use &lt;code&gt;a&lt;/code&gt; tags, you can use &lt;code&gt;span&lt;/code&gt; or any other tag as long as it has the correct classes. It was not super clear in the documentation that that was possible.&lt;/li&gt;
&lt;/ul&gt;&lt;/li&gt;
&lt;li&gt;[ ✅ ] Add h-entry to posts

&lt;ul&gt;
&lt;li&gt;I edited my Blog post layout to include &lt;code&gt;h-entry&lt;/code&gt;, &lt;code&gt;e-content&lt;/code&gt;, and other markup to match the &lt;code&gt;h-entry&lt;/code&gt; spec.&lt;/li&gt;
&lt;/ul&gt;&lt;/li&gt;
&lt;li&gt;[ ✅ ] Allow Webmentions.

&lt;ul&gt;
&lt;li&gt;This looks  a bit more involved since I deploy this site as a static website. I have a few ideas on how to accomplish this, and have read at least one &lt;a href=&#34;https://rowanmanning.com/posts/webmentions-for-your-static-site/&#34; target=&#34;_blank&#34;&gt;post&lt;/a&gt; with some ideas. &lt;em&gt;It will be fun to send a webmention to that site when I have implemented my solution.&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;I managed to add this in &lt;a href=&#34;/blog/2024/03/15/webmentions-enabled/&#34;&gt;March&lt;/a&gt;&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;/li&gt;
&lt;li&gt;[   ] Automatic Post Syndication (POSSE)

&lt;ul&gt;
&lt;li&gt;This is called Publish Own Site Syndicate Elsewhere. Which means that when you publish a post you share a link to your post on other sites, such as Twitter, Threads, or Discord. You can include all the content if the platform supports it, but you can also just include a link back to your primary source.&lt;/li&gt;
&lt;li&gt;I am not really sure how I want to approach this. I already have an RSS feed, so I am part way there. But resurrecting my Twitter account and posting to Threads, Bluesky, and maybe Discord could be a  way to start engaging a wider audience.&lt;/li&gt;
&lt;li&gt;The second part of this is adding syndication links. These are links back to the syndicated post. So for example when I shared a recent post on Bluesky it would look like &lt;code&gt;&amp;lt;a rel=&amp;quot;syndication&amp;quot; class=&amp;quot;u-syndication&amp;quot; href=&amp;quot;https://bsky.app/profile/sloped.me/post/3kmdmhr2h5e2q&amp;quot;&amp;gt;Bluesky&amp;lt;/a&amp;gt;&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Embracing Indie Web is about owning your content, a powerful concept. Implementing this was a great excersise, but I did not find it simple, which is a bit of a let down. I do not have any great solutions, other than hopefully those of us that can do this start building tooling and tutorials to make things simpler for those with less experience and expertise.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>The Joy of Elden Ring</title>
    <link>https://connermccall.com/blog/2024/02/26/joy-elden-ring/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/02/26/joy-elden-ring/</guid>
    <description>Why Elden Ring may be my perfect game, even though I do not have enough time to play it.</description>
    <pubDate>Mon, 26 Feb 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I bought Elden Ring during a Steam sale after it won all the awards in 2022. Historically, I avoided &lt;a href=&#34;https://en.wikipedia.org/wiki/Soulslike&#34; target=&#34;_blank&#34;&gt;Soulslike games&lt;/a&gt;. My gaming as an adult has leaned heavily on Easy Mode and approached games more as interactive stories than challenges of skill. But, given the noise around this game, I decided that a $40 splurge was worth the risk.&lt;/p&gt;

&lt;p&gt;I kind of remember the first few hours being a frustrating slog, but once I had gotten my head around the controls, pacing, and the surprising number of risk levers you could use, I was hooked. I spent hours exploring Limgrave(the starting area), and then by chance found the shortcut to Liurnia(typically your second area). I explored that land far and wide until one day I went back to Limgrave and discovered I had never entered Stormveil. Somehow I had managed to unintentionally avoid the first major dungeon in the game, which was a surprise.&lt;/p&gt;

&lt;p&gt;I kept moving forward, tackling mini bosses, delving into caves, exploring, and dying over and over again in the Royal Capital. Then after reaching Crumbling Farum Azula, I got stuck. Life elsewhere got interesting, house projects started looking enjoyable again, and gaming basically stopped.&lt;/p&gt;

&lt;p&gt;Then this February, the trailer for the expansion was announced. I started thinking about Elden Ring again. I was worried that the loss of muscle memory and timing would make it too frustrating to return to, but then I watched the trailer. I decided I wanted to get back into things, not really because of the expansion, but mostly because it reminded me of how much fun it was to be challenged in a game.&lt;/p&gt;

&lt;p&gt;I fired up the game and immediately took a few passes through some easier areas to get my timing back. Then, as I was thinking I was almost ready to head back to Azula, I noticed a path that I had never taken. It turns out I had ignored a whole region of the map. This highlighted two things I love about this game. One, the game world is so vast and well-designed that not only can you skip an entire dungeon, but you can also stumble upon hours of content that you just randomly missed. The second thing is that the challenges do not scale as you level up. There is pride in defeating bosses that are extremely tough, but there is also a lot of joy to be found in romping through an area you either left intentionally and come back to 20 levels later or discover further down the line than expected. It was kind of the perfect return.&lt;/p&gt;

&lt;p&gt;Of course, the other joy is how the game reminds you to never relax. If you take lowly enemies lightly, you will die. If you challenge an enemy that is your equal, you will probably die die; in fact, it is almost inevitable you will die each time you fire up the game. And I found that surprisingly, I was ok with that.&lt;/p&gt;

&lt;p&gt;Before I played it, the story I told myself about Elden Ring was that constantly losing would be annoying, but instead, the game is built so that even when you &amp;ldquo;die&amp;rdquo; you learn something. You learn the timing of a boss&amp;rsquo;s attack, the location of an ambush, or the fact that that hole is deeper than you anticipated. So you can come back to it and try again, armed with just enough knowledge to get just a bit further. It&amp;rsquo;s a blast and I highly recommend giving it a try.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Logging Made Easy: Docker, Syslog, and Privacy-Focused Analytics</title>
    <link>https://connermccall.com/blog/2024/02/21/docker-logging/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/02/21/docker-logging/</guid>
    <description>How I exported my docker container logs using syslog and started tracking visitors with Goatcounter</description>
    <pubDate>Wed, 21 Feb 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;In my previous &lt;a href=&#34;/blog/home-lab&#34;&gt;home lab&lt;/a&gt; post I noted that I use Docker to run most of my services. That now includes this site in a container I am building, pushing, and deploying automatically whenever I push a commit. (I will need to write a post about that too)&lt;/p&gt;

&lt;p&gt;I also set up &lt;a href=&#34;https://www.goatcounter.com/&#34; target=&#34;_blank&#34;&gt;Goatcounter&lt;/a&gt; &lt;a href=&#34;#note&#34;&gt;*&lt;/a&gt; to do some anonymous tracking of visitors to this site. I mostly want to know about referrers and what content people were finding interesting. I decided I wanted to forgo the JS tracking script in favor of just using server counts.&lt;/p&gt;

&lt;p&gt;Goatcounter supports this out of the box. It expects to either import data occasionally or it can be set to tail a log file. Since by default Docker does not create standard log formats, I had to learn a bit about how to get Docker to send logs to a text file.&lt;/p&gt;

&lt;p&gt;I ended up using rsyslog and some tweaks to my container configuration. The result is a log file for my container, the ability to easily add this same sort of logging to any other container on the host machine, and a Goatcounter service that is sending anonymized data to goatcounter.&lt;/p&gt;

&lt;p&gt;First, you need to setup the logging mechanism in rsyslog.&lt;/p&gt;

&lt;p&gt;Create the file &lt;code&gt;/etc/rsyslog.d/30-docker.conf&lt;/code&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;$FileCreateMode 0644
$template DockerDaemonLogFileName,&amp;quot;/var/log/docker/docker.log&amp;quot;
$template DockerContainerLogFileName,&amp;quot;/var/log/docker/%SYSLOGTAG:R,ERE,1,FIELD:docker/(.*)\[--end:secpath-replace%.log&amp;quot;
if $programname == &#39;dockerd&#39; then {
?DockerDaemonLogFileName
stop
}
if $programname == &#39;containerd&#39; then {
?DockerDaemonLogFileName
stop
}
if $programname == &#39;docker&#39; then {
if $syslogtag contains &#39;docker/&#39; then {
?DockerContainerLogFileName
stop
}
}
$FileCreateMode 0600
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;You will want to reload rsyslog after creating that file &lt;code&gt;sudo systemctl restart rsyslog&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The file creates a new &lt;code&gt;docker.log&lt;/code&gt; file for system log messages. It will also create a file &lt;code&gt;[field].log&lt;/code&gt; where &lt;code&gt;[field]&lt;/code&gt; is replaced by the second part of the &lt;code&gt;log_opts&lt;/code&gt; tag.&lt;/p&gt;

&lt;p&gt;In terraform you get this by adding &lt;code&gt;log_opts&lt;/code&gt; to your container, along with setting the log_driver as &lt;code&gt;syslog&lt;/code&gt;&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;resource &amp;quot;docker_container&amp;quot; &amp;quot;containername&amp;quot; {
  name  = &amp;quot;containername&amp;quot;
  image = &amp;quot;registry.me/imagename:latest&amp;quot;

...

  log_opts = {
    tag = &amp;quot;docker/containername&amp;quot;
  }

  log_driver = &amp;quot;syslog&amp;quot;

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

&lt;p&gt;Or on the command line&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;docker run -d \
  --name containername \
  --log-driver=syslog \
  --log-opt tag=docker/containername \
  registry.me/imagename:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now when this container starts, any log messages it sends to stdout or stderr are logged to &lt;code&gt;/var/log/docker/containername.log&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;I also recommend setting up some log rotation for those files so you do not fill your filesystem if you have noisy containers.&lt;/p&gt;

&lt;p&gt;Now create &lt;code&gt;/etc/logrotate.d/rsyslog-docker&lt;/code&gt; to have logrotate monitor and rotate my logs.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;/var/log/docker/*.log
{
weekly
rotate 10
minsize 200M
missingok
notifempty
compress
sharedscripts
postrotate
/usr/lib/rsyslog/rsyslog-rotate
endscript
}
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Finally, I downloaded the goatcounter binary and told it to tail the log. &lt;code&gt;/etc/systemd/system/goatcounter-blog.service&lt;/code&gt;.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;[Unit]
Description=GoatCounter Import Service
After=network.target

[Service]
Environment=GOATCOUNTER_API_KEY=[your key]
User=[user]
WorkingDirectory=/home/[user]
ExecStart=/home/[user]/goatcounter import -follow -format=combined -exclude=static -exclude=&#39;path:glob:/fonts/**&#39; -site=&#39;https://goatcounter.com&#39; /var/log/docker/containername.log
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;You will need to enable this service with&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;sudo systemctl enable goatcounter-blog.service
sudo systemctl start goatcounter-blog.service
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now, assuming the log files created by the container match the Combined Log Format, you will see visits added to your Goatcounter statistics.&lt;/p&gt;

&lt;p&gt;One caveat to this approach is that Docker does not give you an easy way to differentiate between &lt;code&gt;stderr&lt;/code&gt; and &lt;code&gt;stdout&lt;/code&gt; so all messages go to your single log file. Ideally there would be a way to differentiate between the two messages types and send them to a .log or a .error file, but that does not appear to be possible today.&lt;/p&gt;

&lt;p&gt;*&lt;em&gt;&lt;a id=&#34;note&#34;&gt;&lt;/a&gt;If you want to self host Goatcounter yourself, take a look at my &lt;a href=&#34;https://hub.docker.com/r/sloped/goatcounter&#34; target=&#34;_blank&#34;&gt;Docker container&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Signing Git Commits with SSH</title>
    <link>https://connermccall.com/blog/2024/02/13/ssh-signing-keys/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/02/13/ssh-signing-keys/</guid>
    <description>Learning you can sign commits with an ssh key</description>
    <pubDate>Tue, 13 Feb 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I was recently playing around with 1Password&amp;rsquo;s SSH key features. I have a few keys saved in my vault, but normally I use keys that were generated on the host and have never left the host. My curiosity led me to add a new key and utilize it as a login key for another system. (This works great, by the way!)&lt;/p&gt;

&lt;p&gt;Once I had created that key, 1Password prompted me to use it to sign my git commits. What??&lt;/p&gt;

&lt;p&gt;I have used GPG signing in the past, but the setup experience was painful enough that I mostly avoided it. I sign most everything on my main machine with a GPG key, but other hosts do not have signing enabled. Since I already have SSH keys created for those hosts, being able to just use those keys would be a nice little win.&lt;/p&gt;

&lt;p&gt;[!note]
Git introduced SSH signing in &lt;code&gt;2.34.0&lt;/code&gt;, so you will need to ensure your version of Git is that version or newer. Ubuntu Focal does not have that version yet, so I ended up adding the &lt;a href=&#34;https://git-scm.com/download/linux&#34; target=&#34;_blank&#34;&gt;Git ppa&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you are wondering why you might want to sign your commits, &lt;a href=&#34;https://security.stackexchange.com/questions/134164/why-should-i-sign-git-commits-tags-should-i-sign-both-commits-and-tags&#34; target=&#34;_blank&#34;&gt;this post has a decent summary&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you are using standard Git your ~/.gitconfig file will look like this&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;[user]
	name = [Your name]
	email = [Your email]
	signingkey = [Path to your public key]
[gpg]
	format = ssh
[commit]
	gpgsign = true
[core]
	editor = vim
[gpg &amp;quot;ssh&amp;quot;]
	allowedSignersFile = [Path to an allowed signers file]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;If you want to use 1Password, it is a bit different. You point to the 1Password SSH binary and will authorize with 1Password when you need to sign.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;[user]
	name = [Your name]
	email = [Your email]
	signingkey = [Path to your public key]
[gpg]
	format = ssh
[gpg &amp;quot;ssh&amp;quot;]
	  program = &amp;quot;/opt/1Password/op-ssh-sign&amp;quot;
[commit]
	gpgsign = true
[core]
	editor = vim
[gpg &amp;quot;ssh&amp;quot;]
	allowedSignersFile = [Path to an allowed signers file]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;The final step is setting up an allowedSignersFile so you can actually verify commits.&lt;/p&gt;

&lt;p&gt;The format for this file is an identifier + key. So mine looks like&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;conner@fungi ssh-ed25519 [Public key on host fungi]
conner@connermccall.com ssh-ed25519 [Public key on current host]
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Now if you run &lt;code&gt;git log --show-signature&lt;/code&gt; you should see info on the signed commits. Notice the &lt;code&gt;Good &amp;quot;git&amp;quot; signature&lt;/code&gt; contains a SHA and the identifier. You can make the identifier be anything in your Allowed Signers File. Emails work great, but if you want use a collaborators nickname or full name go right ahead.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;commit 612beb7b61b0137d50b5570f4b4c0514235ea4c4 (HEAD -&amp;gt; main, origin/main, origin/HEAD)
Good &amp;quot;git&amp;quot; signature for conner@fungi with ED25519 key SHA256:+YIFq85xEaWEk31Ri4Df83sk8
Author: Conner McCall &amp;lt;conner@connermccall.com&amp;gt;
Date:   Tue Feb 13 17:30:55 2024 +0000

    remove unnecessary env file

commit 793a53ee44471034fbfb0d4eafc3c67467ffa2fe
Good &amp;quot;git&amp;quot; signature for conner@connermccall.com with ED25519 key SHA256:5GYdL9pT2jFdLC7TJiZzJgtcXcBNEBSIX8
Author: Conner McCall &amp;lt;conner@connermccall.com&amp;gt;
Date:   Tue Feb 13 10:58:26 2024 -0600

    feat: add home dns entry
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;You can also enable verification of your keys on GitHub and other providers as well. This lets others see that you have signed your commits and that the provider has verified the signature. This can help improve trust between users.&lt;/p&gt;

&lt;p&gt;Some resources I used when setting this up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://blog.dbrgn.ch/2021/11/16/git-ssh-signatures/&#34; target=&#34;_blank&#34;&gt;https://blog.dbrgn.ch/2021/11/16/git-ssh-signatures/&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key&#34; target=&#34;_blank&#34;&gt;https://docs.github.com/en/authentication/managing-commit-signature-verification/telling-git-about-your-signing-key&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://dev.to/janderssonse/git-signoff-and-signing-like-a-champ-41f3&#34; target=&#34;_blank&#34;&gt;https://dev.to/janderssonse/git-signoff-and-signing-like-a-champ-41f3&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
</content:encoded></item>

<item>
    <title>Another URL shortner</title>
    <link>https://connermccall.com/blog/2024/01/26/go-redirector/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/01/26/go-redirector/</guid>
    <description>Writing a go url shortner</description>
    <pubDate>Fri, 26 Jan 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I recently focused on bringing a &amp;ldquo;URL shortening&amp;rdquo; service in-house. While I don&amp;rsquo;t frequently use URL shorteners, they come in handy for sharing things like NextCloud links or article URLs with friends and family. Having my own system avoids tracking and allows me to control the lifespan of the URLs.&lt;/p&gt;

&lt;p&gt;Previously, I used Netlify for this purpose, which conveniently manages redirects through a single config file (&lt;a href=&#34;https://docs.netlify.com/routing/redirects/&#34; target=&#34;_blank&#34;&gt;Netlify Redirects Documentation&lt;/a&gt;). This setup was efficient: I simply updated a Git repository linked to a Netlify site. Adding a new redirect was as easy as committing and pushing changes, or editing the file directly in the web IDE.&lt;/p&gt;

&lt;p&gt;To achieve similar functionality on my own server, I developed &lt;a href=&#34;https://github.com/sloped/go-redirector&#34; target=&#34;_blank&#34;&gt;go-redirector&lt;/a&gt;. My prior experience with Go, building a personal API for various tasks (similar to IFTTT, but self-coded), assured me it was a suitable choice.&lt;/p&gt;

&lt;p&gt;Go-redirector is straightforward. It stores redirects in the same format as Netlify and, upon startup, the server reads this file, registering each line as a separate route. It&amp;rsquo;s a lean solution, with an image size of around 15MB, focused solely on redirecting from a path to a URL.&lt;/p&gt;

&lt;p&gt;For more details on its usage, you can check the repository.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Moving to OpenTofu</title>
    <link>https://connermccall.com/blog/2024/01/09/opentofu/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/01/09/opentofu/</guid>
    <description>Migrating from Terraform to Tofu</description>
    <pubDate>Tue, 09 Jan 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I recently decided to migrate some of my &lt;a href=&#34;/blog/home-lab&#34;&gt;services&lt;/a&gt; from Terraform to &lt;a href=&#34;https://opentofu.org/manifesto&#34; target=&#34;_blank&#34;&gt;OpenTofu&lt;/a&gt;. This was driven by HashiCorp&amp;rsquo;s licensing decisions and by curiousity. Since this change currently only impacts my personal projects, the risk seems minimal, and tackling it now rather than later should be simpler.&lt;/p&gt;

&lt;p&gt;The instruction provided by &lt;a href=&#34;https://opentofu.org/docs/intro/migration/&#34; target=&#34;_blank&#34;&gt;OpenTofu&lt;/a&gt; are straightfoward. I run PopOS so I used the instruction provided for Debian to install. Once that was done upgrading was mostly a non-event.&lt;/p&gt;

&lt;p&gt;First, I ran a terraform plan to ensure there were no pending changes.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;terraform plan
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Next, I backed up the current state:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;tofu state pull &amp;gt; backup-before-tofu.tfstate
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;I recommend a quick review of that file to ensure nothing looks odd.&lt;/p&gt;

&lt;p&gt;Then run init to install the correct tofu providers.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;tofu init
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Once you have completed this, ideally you can create a plan and nothing will need to change&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;tofu plan
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;In my case, it turns out that the docker provider, &lt;code&gt;kreuzwerker/terraform-provider-docker&lt;/code&gt; had release a major version since I last upgraded providers. This added a little bit of fun. If you run into this the only thing I really had to change was removing the &lt;code&gt;latest&lt;/code&gt; data reference on your &lt;code&gt;docker_image&lt;/code&gt; &lt;a href=&#34;https://github.com/kreuzwerker/terraform-provider-docker/blob/master/docs/v2_v3_migration.md&#34; target=&#34;_blank&#34;&gt;migration guide&lt;/a&gt;. I initially used the suggestion of &lt;code&gt;repo_digest&lt;/code&gt;, but for some reason that meant it tried to replace my container on every run of &lt;code&gt;tofu plan&lt;/code&gt;. I ended up switching to &lt;code&gt;id&lt;/code&gt; for now. Not sure how that will impact upgrades, but I will deal with that when we get there.&lt;/p&gt;

&lt;p&gt;The other issue I ran into is that the new version picked up a &lt;code&gt;/etc/docker/daemon.json&lt;/code&gt; change to push docker logs to loki. Since I did not have this in the container definitions, it was forcing a container replacement on every plan as well. I added the following to each container to prevent the replacements.&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;log_opts = {
    &amp;quot;loki-batch-size&amp;quot; = &amp;quot;400&amp;quot;,
    &amp;quot;loki-url&amp;quot; = &amp;quot;http://localhost:3100/loki/api/v1/push&amp;quot;
  }
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Once you resolve any issues, making a small change to validate OpenTofu can make updates is a good idea. I just added a nonsense environment change to one of my containers.&lt;/p&gt;

&lt;p&gt;I have a few other states to switch over, so I will update this post if I run into any more issues.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Updating Terraform configuration</title>
    <link>https://connermccall.com/blog/2024/01/08/terraform-nonsense/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/01/08/terraform-nonsense/</guid>
    <description>Updating terraform configuration from pre 0.13 + fixing bad state values</description>
    <pubDate>Mon, 08 Jan 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I recently encountered an issue with an outdated Terraform configuration. When I attempted to run &lt;code&gt;terraform plan&lt;/code&gt;, I was notified of the need to upgrade my configuration. The challenge was that the Terraform version I have installed on my desktop no longer supported the &lt;code&gt;0.13upgrade&lt;/code&gt; command. Thankfully, Docker came to the rescue. After some experimentation, I successfully executed the upgrade using this command:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;docker run -v $(pwd):/ -i -t hashicorp/terraform:0.13.0 0.13upgrade
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Feeling confident, I proceeded to update a CNAME entry. However, I encountered an error due to an outdated attribute from my AWS provider. The error message indicated an unsupported attribute &lt;code&gt;vpc_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My solution involved a few steps. First, I retrieved the state file from my S3 remote backend:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;docker run -e AWS_ACCESS_KEY_ID=xxx -e AWS_SECRET_ACCESS_KEY=xxx -v $(pwd):/src -w /src -i -t hashicorp/terraform:0.13.0 state pull &amp;gt; state.json
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Next, I edited the JSON file. For me, this meant removing a vpc_id from a Route53 zone and incrementing the top-level serial value to avoid push failures.&lt;/p&gt;

&lt;p&gt;After fixing the JSON file, I pushed the updated state back:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;docker run -e AWS_ACCESS_KEY_ID=xxx -e AWS_SECRET_ACCESS_KEY=xxx -v $(pwd):/src -w /src -i -t hashicorp/terraform:0.13.0 state push state.json
&lt;/code&gt;&lt;/pre&gt;

&lt;h3 id=&#34;success&#34;&gt;Success&lt;/h3&gt;

&lt;p&gt;Initially, I continued using Terraform 0.13 via Docker to plan and apply my updates:&lt;/p&gt;

&lt;pre&gt;&lt;code&gt;docker run -e AWS_ACCESS_KEY_ID=xxx -e AWS_SECRET_ACCESS_KEY=xxx -v $(pwd):/src -w /src -i -t hashicorp/terraform:0.13.0 plan --out new.tfplan
docker run -e AWS_ACCESS_KEY_ID=xxx -e AWS_SECRET_ACCESS_KEY=xxx -v $(pwd):/src -w /src -i -t hashicorp/terraform:0.13.0 apply new.tfplan
&lt;/code&gt;&lt;/pre&gt;

&lt;p&gt;Eventually, I was able to revert to using my locally installed Terraform. While updating another CNAME, I confirmed that everything was functioning correctly.&lt;/p&gt;

&lt;p&gt;This experience highlighted one of the potential drawbacks of infrastructure as code. Had I used AWS tooling or the console directly, this update would have taken significantly less time. However, I still believe the benefits of using Terraform justify its use in certain scenarios and using it for simpler tasks help pressure test it and keep my knowledge a bit fresher.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>My Homelab</title>
    <link>https://connermccall.com/blog/2024/01/05/home-lab/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/01/05/home-lab/</guid>
    <description>A brief overview of my home lab</description>
    <pubDate>Fri, 05 Jan 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;For many years, I&amp;rsquo;ve been transitioning my digital life onto servers (mostly) under my control. I manage two servers: a Droplet on Digital Ocean and another hosted in my basement.&lt;/p&gt;

&lt;p&gt;When I began my self-hosting journey, my home internet was cable with slow upload speeds. This led me to set up a Droplet on Digital Ocean. I used the old standard of a single Nginx/Apache server with various virtual hosts. Keeping things updated and configuration managed was a pain.&lt;/p&gt;

&lt;p&gt;I discovered Terraform at work and really fell for infrastructure as code. I did some digging and discovered that you could manage Docker with Terraform. I messed around with things a bit and then spent a weekend moving all my services into separate containers, all managed by Terraform. Adding a new one, updating a current service, or removing a service is all done in configuration files that I can keep locally. I really like this setup for several reasons.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Better Service Isolation:&lt;/strong&gt; Each service runs in its own container, allowing for precise resource allocation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Easier Updates:&lt;/strong&gt; Updates are streamlined; I just change the image tag in Terraform and apply the update. Rollbacks are nearly as simple.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version Control for System Decisions:&lt;/strong&gt; Enables tracking changes and decision history.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Centralized Configuration Files:&lt;/strong&gt; I keep each service&amp;rsquo;s configuration in a single zfs dataset, backing this up is trivial.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Auto Provisioning of Certificates:&lt;/strong&gt; Utilizes the nginx LetsEncrypt proxy companion for hassle-free certificate management.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The installation of Fiber internet a year ago allowed me to migrate a bunch of services to my in-home server. I simply updated the Route53 Terraform entry, transferred the data, and replicated the Terraform configurations from Digital Ocean. Running identical Docker images meant most things worked seamlessly.&lt;/p&gt;

&lt;p&gt;Here&amp;rsquo;s a rundown of most of the services I currently run on each server:&lt;/p&gt;

&lt;h3 id=&#34;in-home-server&#34;&gt;In-Home Server&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href=&#34;https://frigate.video&#34; target=&#34;_blank&#34;&gt;Frigate&lt;/a&gt; - A ML enhanced NVR. This collects the video streams from multiple cameras and does object and motion detection.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.home-assistant.io/&#34; target=&#34;_blank&#34;&gt;Home Assistant&lt;/a&gt; - Home automation software.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.kimai.org/&#34; target=&#34;_blank&#34;&gt;Kimai&lt;/a&gt; - Time tracking software.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://nextcloud.com/&#34; target=&#34;_blank&#34;&gt;Nextcloud&lt;/a&gt; - Data hub, primarily used as a Dropbox alternative.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://nodered.org/&#34; target=&#34;_blank&#34;&gt;Node Red&lt;/a&gt; - Programming tool for IoT devices.&lt;/li&gt;
&lt;li&gt;A Personal API - A little go service I randomly add to.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.photoprism.app/&#34; target=&#34;_blank&#34;&gt;Photoprism&lt;/a&gt; - A ML photo collection service. Slowly replacing Google Photos with this.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://pi-hole.net/&#34; target=&#34;_blank&#34;&gt;PiHole&lt;/a&gt; - DNS with ad and malicious site blocking.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.plex.tv/&#34; target=&#34;_blank&#34;&gt;Plex&lt;/a&gt; - TV, Movie, and Music library.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/Wingysam/Christmas-Community&#34; target=&#34;_blank&#34;&gt;Christmas Community&lt;/a&gt; - A little wishlist site that I set up for my family.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://github.com/sissbruecker/linkding/&#34; target=&#34;_blank&#34;&gt;Linkding&lt;/a&gt; - A link collection service.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://syncthing.net/&#34; target=&#34;_blank&#34;&gt;Syncthing&lt;/a&gt; - Makes syncing data easy. I use this on my desktop, laptop, and phone as well.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.stirlingtools.com/&#34; target=&#34;_blank&#34;&gt;Stirling PDF&lt;/a&gt; - A collection of PDF tools. Self-hosted so I can use it on things with PII.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3 id=&#34;digital-ocean&#34;&gt;Digital Ocean&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href=&#34;https://about.gitea.io/&#34; target=&#34;_blank&#34;&gt;Gitea&lt;/a&gt; - Self-hosted GitHub alternative.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://grafana.com/&#34; target=&#34;_blank&#34;&gt;Grafana&lt;/a&gt; - Metrics and Dashboards. Not using this much lately.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.monicahq.com/features/dashboard&#34; target=&#34;_blank&#34;&gt;Monica CRM&lt;/a&gt; - A personal CRM. The goal is to keep track of personal relationships. I am still not good at using this.&lt;/li&gt;
&lt;li&gt;Nextcloud - This was my original location, slowly migrating to my home server.&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://onetimesecret.com/&#34; target=&#34;_blank&#34;&gt;One-Time Secret&lt;/a&gt; - Secret Sharing. Self-hosting this mostly for fun. I mostly trust the official version.&lt;/li&gt;
&lt;li&gt;Syncthing&lt;/li&gt;
&lt;/ol&gt;
</content:encoded></item>

<item>
    <title>Pasta Quest</title>
    <link>https://connermccall.com/blog/2024/01/04/pasta-quest/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/01/04/pasta-quest/</guid>
    <description>A carb loading resolution</description>
    <pubDate>Thu, 04 Jan 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;I was scrolling through Instagram the other day and came across a post about resolutions. It argued that resolutions shouldn’t be dreaded tasks. I wish I could find it again because it had some great examples that really drove the point home.&lt;/p&gt;

&lt;p&gt;The one example I remember was eating as many different pasta shapes as you can in a year, dubbed the Pasta Quest.&lt;/p&gt;

&lt;p&gt;I loved the idea. I&amp;rsquo;m a big pasta fan and eat more than I probably should, but I haven&amp;rsquo;t put a concerted effort into trying different shapes. So, 2024 is the year of Pasta Quest. I haven&amp;rsquo;t set a specific target for the number of shapes, but I&amp;rsquo;m hoping to try at least a dozen new ones along with a bunch of classics.&lt;/p&gt;

&lt;p&gt;&lt;img src=&#34;../../images/pasta/rotelle.jpg&#34; alt=&#34;Box of Rotelle pasta&#34; /&gt;&lt;/p&gt;

&lt;p&gt;So far in 2024, I&amp;rsquo;ve tried one shape: Rotelle. More to come. I might put up a page once I&amp;rsquo;ve made more progress.&lt;/p&gt;
</content:encoded></item>

<item>
    <title>Restoring an SVN Backup</title>
    <link>https://connermccall.com/blog/2024/01/02/svn-restoration/</link>
    <guid isPermaLink="true">https://connermccall.com/blog/2024/01/02/svn-restoration/</guid>
    <description>Restoring access to svn from a backup</description>
    <pubDate>Tue, 02 Jan 2024 00:00:00 UTC</pubDate>
    <content:encoded>&lt;p&gt;One of the projects I&amp;rsquo;m currently involved with requires taking over a technical infrastructure that was built over the past 15 years. It includes numerous tools, some of which haven&amp;rsquo;t been updated or used in years. Depending on our client&amp;rsquo;s needs, we might need to use these tools at some point. Documentation is often missing or scattered across many locations.&lt;/p&gt;

&lt;p&gt;Recently, while trying to get one of these tools running, I suspected that the solution to a problem might be documented in some old code. The more recent code is hosted on a GitLab instance, but much of the older code was never migrated from SVN. Fortunately, I had access to an SVN backup output.&lt;/p&gt;

&lt;p&gt;I ended up with a .svn file that was 33GB. A bit of research revealed that such files are typically created by running svnadmin dump and can be imported using the same tool.&lt;/p&gt;

&lt;p&gt;After some digging, I found several Docker images suitable for running an SVN server. I opted for krisdavison/svn-server. I ran this image while mounting the backup and a /home/svn directory to store the restored data.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker run -v $(pwd)/svn:/home/svn -v $(pwd):/backup -p 80:80 krisdavison/svn-server:v3.0&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Once this was running, I had to create a new SVN repository with &lt;code&gt;svnadmin create /home/svn/breeze&lt;/code&gt;, which creates a new blank repository. Then, I imported the backup using &lt;code&gt;svn admin load /home/svn/breeze &amp;lt; svndump.svn&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The restore process took a lot longer than I expected. I leanred that &lt;code&gt;svn admin&lt;/code&gt; replays the commits insquence over the repository. It was fascinating to watch the file system evolve in real-time. I particularly noticed the moment the repository transitioned from a flat repository into a &lt;a href=&#34;https://stackoverflow.com/questions/698313/what-is-trunk-branch-and-tag-in-subversion&#34; target=&#34;_blank&#34;&gt;trunk-branch-tag&lt;/a&gt; structure.&lt;/p&gt;

&lt;p&gt;Interestingly, the loaded SVN repository was only 17GB, while the dump file was 33GB. This initially caused me some concern, but further research clarified that svndump by default includes entire files for each revision, whereas SVN stores deltas with occasional snapshots. Considering the 2478 revisions, this size difference seems reasonable.&lt;/p&gt;
</content:encoded></item>

</channel></rss>