Rehousing my blog
2/20/2026
I've been dissatisfied with SubStack for a little while, and finally got my act together to move to a self-hosted site where I have more control.
I’ve been writing pretty regularly on my SubStack since mid-2024. Initially weekly, then bi-weekly (alternating with my Ocuroot blog), and back to weekly again in recent months. The writing itself has been mostly satisfying, giving me nice excuses for fun little side projects, but text with the occasional image is starting to feel a bit limiting. So I’m going to be writing on my own site instead!
I’ve re-vamped telliott.me to be a first port-of-call for my writing, but will likely cross-post to SubStack and possibly my LinkedIn newsletter for a while. So you don’t need to do anything right away if you’re subscribed to either of those, although if you prefer, there’s a handy-dandy RSS feed on my site. Promise I won’t be sad if I see you unsubscribe from the newsletters to follow along that way.
Housekeeping out of the way, let’s talk about motivations and some of the tools I’m using.
Motivations
My biggest frustration with SubStack, LinkedIn Newsletters, and other social media blog platforms is their “social mediay-ness”. They can help you find an audience, but you have to do it on their terms. Farming engagement, exchanging recommendations and posting short updates between posts. I’d find myself repeatedly refreshing the stats for each post the day it went live, as if it mattered how quickly people found it. I just want to write about things I’ve built, things I’ve learned and maybe share the occasional controversial opinion.
What finally got me to move, however, was the limited palette of tools in the editor. Code blocks don’t provide syntax highlighting or a nice way to handle long lines. Embedding a table in a recent post required me to go to a third-party site and drop in an iFrame. I want more flexibility and control. Maybe this way I’ll even start dabbling in interactive content!
Tools
The previous version of telliott.me was vibe coded as an Astro site. This was barely scratching the surface of what Astro could do, and working through the Build a Blog tutorial I got a much better grounding in what the framework was capable of.
In particular the concept of collections stood out as a useful way to organize content. Alongside blog posts, I had short lists of podcasts I’d appeared on and talks I’d given. My content.config.js file ended up looking like this:
import { glob } from "astro/loaders";
import { defineCollection } from "astro:content";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ pattern: '**/[^_]*.md', base: "./src/blog" }),
schema: z.object({
title: z.string(),
slug: z.string(),
pubDate: z.date(),
description: z.string(),
author: z.string().optional(),
image: z.object({
url: z.string(),
alt: z.string(),
caption: z.string().optional()
}).optional(),
})
});
const podcasts = defineCollection({
loader: glob({ pattern: '**/*.yaml', base: "./src/podcasts" }),
schema: z.object({
title: z.string(),
date: z.string(),
description: z.string(),
video_url: z.string(),
})
});
const talks = defineCollection({
loader: glob({ pattern: '**/*.yaml', base: "./src/talks" }),
schema: z.object({
title: z.string(),
event: z.string(),
date: z.string(),
description: z.string(),
slides_url: z.string(),
video_url: z.string().optional(),
})
});
export const collections = { blog, podcasts, talks };
(If you’re reading on my main site, you’ll notice the helpful syntax highlighting!)
I’m using CloudFlare Pages for hosting, enjoying their more than generous free tier. However, for a little more flexibility, I’ve disabled the auto-deploy feature and moved to using GitHub Actions for CI. This let me add some Playwright tests, which the Pages build environments currently don’t support. As of right now, the tests are just to make sure that the menu works on desktop and mobile (I’ve been burned by that one before), but as I add some more complexity, I’m sure I’ll have plenty more to add.
The workflow currently looks like this:
name: Deploy to CloudFlare Pages
on:
push:
branches:
- main
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
deploy:
needs: test
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build site
run: npm run build
- name: Deploy site to CloudFlare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
command: pages deploy dist --project-name telliott-me
This gives a certain amount of peace of mind, but does come at a cost. The Playwright tests take about 2-3 minutes to execute on a standard runner, which will likely be a pain if I’m just trying to fix a typo.
Of course, this reveals another, hidden motivation behind this move. I have an excuse to play around with CI configuration to my heart’s content! I’m back in my happy place.