swan tron dot com

Failing the PR when the new code isn't tested

Every repo I’ve worked in has a coverage number, and the number lies. Ours says 84%. It’s said 84% for about a year. It is, in the most literal sense, true — and it tells me almost nothing about the change sitting in front of me.

A project-wide coverage percentage is the HEAD / → 200 of testing. Someone opens a PR, adds a hundred lines with no tests, and the global number drifts from 84.1% to 83.6%. Nobody blocks a PR over half a percent — you’d look insane doing it. So the untested code walks right in, the number stays roughly flat, and six months later “84% covered” is hiding a steadily growing pile of stuff nobody ever wrote a test for. The aggregate is exactly the wrong lens: it’s biggest where it matters least, and it barely flinches at the one thing you’d actually want to catch.

Same instinct as the other gates I keep building — uptime, deploy — pointed at a different lie.

The Idea

Stop grading the whole codebase. Grade the diff.

It’s called delta coverage, or patch coverage, and the move is simple: correlate the git diff with the coverage report and hold only the lines this PR changed to a threshold. The global number can sit at 84% forever — I don’t care. I care that the hundred lines you just wrote are tested. If they’re not, the PR doesn’t merge. Coverage debt can’t slip in behind an aggregate that barely moves, because the aggregate isn’t the gate anymore. Your diff is.

This isn’t a new idea — Codecov and friends have done patch coverage for years. I wanted a version that was dead simple, language-agnostic, and ran entirely inside my own CI.

What I Built

difftron is a small Go CLI that takes a coverage report — LCOV, Cobertura, or Go’s native format — diffs HEAD against the base branch, and reports coverage on the changed lines only. Because it speaks those three formats, it doesn’t care what language you’re in: anything that can emit one of them works. Node, Python, Go, Rust, whatever — if your test run produces an lcov file, difftron can gate it.

Then I wrapped it as a GitHub Action and put it on the Marketplace, so wiring it up is this:

- uses: swantron/difftron@v1
  with:
    coverage: coverage/lcov.info
    threshold: '80'

It posts a sticky comment on the PR with the per-file breakdown and the exact uncovered line numbers, and it fails the check when your changed lines come in under threshold. That’s the whole thing. It’s free, it runs in your CI, and nothing — not your source, not your coverage — leaves the runner.

Dogfooding the fleet

The real test of “language-agnostic” is putting it on things that aren’t the repo it was written in. So I spent an afternoon dropping it onto six of my own: the Go tools (go test -coverprofile and you’re done), a couple of React apps on vitest, and two that needed c8 to wrap a plain node --test into an lcov file. One config shape, six repos, every coverage toolchain I own.

Each one caught the untested code I deliberately threw at it — a tested function and an untested one in the same PR, and difftron credited the first and flagged the second every time, in every ecosystem. Watching a Go repo, a vitest repo, and a node:test monorepo all light up the same sticky comment was the moment it felt real.

The honest bug that fell out of dogfooding: the first version failed a docs-only PR. Someone edits a README, difftron sees a changed file, finds no coverage data for it — because it’s markdown, of course it has none — decides that means 0% covered, and fails the PR. That’s an instant uninstall. The fix is a small bit of philosophy: difftron has no coverage signal for a README or a YAML, so it shouldn’t pretend it does. Files with no coverage data get skipped, not failed, and the comment says exactly which ones it skipped. Touch only docs, and the gate passes clean and tells you why.

The caveats, because there are always caveats

  • It’s a gate, not a coverage SLO. It says “the lines in this PR are tested.” It does not say “this codebase is well tested.” Different question, on purpose.
  • It only knows what your coverage report tells it. If your test tool doesn’t emit a file for something, difftron can’t grade it — it’ll skip it and say so out loud rather than guess. Garbage in, honestly-labeled garbage out.
  • It’s playing in a crowded pool. Codecov, Coveralls, Sonar all do patch coverage, and they have hosted dashboards and org-wide policy that difftron doesn’t. The thing they structurally can’t match: difftron runs entirely inside your CI. No upload, no token, no third party ever seeing your code. For a lot of teams that’s the whole ballgame.

Try It

Marketplace: Difftron Delta Coverage Gate

Source: github.com/swantron/difftron

A few lines of yaml, $0, runs where your tests already run — and your next untested PR doesn’t get to merge quietly. Check it out ^

The deploy gate, evolved: proving a deploy with OpenTelemetry

I sort of wound up with a formidable little fleet of sites.. tronswan, chomptron, swantron, mt, wrenchtron, a self-hosted bluesky pds. The DevX dawg in me naturally wanted to know if they were up.

So I wrote the dead simplest thing that could show me. uptime-monitor is 234 lines in one file — a GitHub Actions cron that pings every site every 5 minutes and writes uptime % and incidents to a public gist. No VM, no terraform, no control plane. It runs at $0 and only tips over when GitHub tips over. For “is it up, over time, and when did it break,” it’s honestly hard to beat. It’s still my heartbeat, and I’d build it the same way again.

But it answers one question, and there’s another it can’t touch: did this deploy actually work?

Every site deploys from CI, and every deploy hands me a green checkmark when it’s done. The checkmark means the pipeline exited 0 — not that the site is up, serving the code I just shipped, to a real request. A HEAD / coming back 200 can be a CDN handing you a cached page while the origin behind it is face-down. And the uptime monitor won’t save me here: a cached 200 looks “up” to a pinger too, and a five-minute cron isn’t tied to the deploy that just went out.

The usual answer to that is a post-deploy smoke test — curl the homepage in CI, check for a 200, move on. That’s a deploy gate, and it beats nothing. I do lean on a lot of post-deployment Playwright jobs, but that’s classic overhead. Still.. a 200 is a low bar, and I wanted to improve on it: a gate that proves the new build is serving real requests before the deploy is allowed to call itself done. Same instinct as the uptime monitor — small, cheap, $0 — aimed at a different question.

The Approach

We’re going OTel. OpenTelemetry, but pointed backwards. Instead of instrumenting for dashboards you read after something already broke, use it as a gate. Right after a deploy: fire synthetic traffic at the live URL, tag every request with a W3C trace id, and check that the telemetry for that exact run actually lands. If it lands, the request flowed all the way through to the real application — not an edge cache, not a stale instance. If it doesn’t land within the latency and availability you expect.. fail the deploy.

A CI step can lie about an exit code. It can’t fake a span showing up in a buffer.

What I Built

watchtron is the paved road for that. A few moving parts:

  • A prober (undici) that drives golden-signal traffic at a service’s critical routes, propagates trace context, and exports its client spans as OTLP/HTTP JSON.
  • A tiny control plane on a free-tier GCE e2-micro — receives the spans, runs /verify, serves a dashboard and status badges, Caddy out front for auto-HTTPS. The whole thing runs at $0.
  • A reusable GitHub Actions workflow any repo calls right after its deploy job. The deploy is gated on the verdict.

Onboarding a service is basically this:

verify:
  needs: deploy
  uses: swantron/watchtron/.github/workflows/verify.yml@main
  with:
    service: tronswan
    version: ${{ github.sha }}
  secrets:
    otlp_endpoint: ${{ secrets.WATCHTRON_OTLP_ENDPOINT }}
    token: ${{ secrets.WATCHTRON_TOKEN }}

For the two primary services I homeroll runtimes for (tronswan, chomptron) there’s a drop-in @swantron/otel-bootstrap so the origin emits its own server span. That’s the good part — it lets the control plane confirm the synthetic request actually reached the instrumented app and stitch the two halves of the trace together.

What It Verifies

For each run, the control plane scores:

  • availability and p95 latency over the synthetic burst
  • every critical route actually got probed
  • (white-box) a server span correlated with the prober’s trace — the request truly reached the origin, not a cache
  • (white-box) version assertion — the origin reports the git SHA it’s running, and verify checks it against the SHA being deployed. green now means this build is serving, not just something
  • a latency regression check against a rolling baseline, to catch slow creep that’s still technically under the limit

Anything fails, the deploy fails. The control plane is the authority, not the CI log.

The Honest Comparison

Once watchtron grew up a bit — a dashboard, persistence on the little VM, regression baselines — I had to be honest with myself about something. A chunk of what I was bolting on was quietly wandering into the exact territory the 234-line gist-pinger already owned: continuous health over time. And on that front it was losing. The pinger is cheaper, simpler, runs 6x more often, and actually keeps history. Keep going down that road and I’d just be rebuilding the uptime monitor, worse.

So I drew a line and stopped making them compete. They’re not the same job. uptime-monitor is the heartbeat — is it up, over time, when did it break? watchtron is the deploy gate — did this deploy ship working, instrumented code, and should we let it through? A cron pinger can’t fail a deploy or prove the new version is serving. Two small $0 tools, two different questions. Both viable.

Once I drew that line, the nice part showed up.

The Combined View

Both tools watch the same fleet, so I stopped duplicating and started overlaying. Take the uptime monitor’s continuous up/down timeline, and drop watchtron’s verified-deploy markers right on it. One strip per service: green up, red down, and little triangles where deploys landed — green if the deploy proved itself end-to-end, red if it didn’t.

Now I can actually see whether a deploy lines up with a dip. The reliability timeline and the deploy provenance, on the same axis. Neither tool gives you that on its own.

It lives on watchtron’s dashboard at watch.swantron.com, and in a cleaner public cut on tronswan.com/status.

watchtron dashboard: per-service uptime strips with verified-deploy markers

Caveats

  • It’s a gate, not an SLO. The score comes off a small synthetic burst fired right after deploy. It tells you “the new build answered fast and correctly just now,” not “we hit four nines this quarter.” Calling it an SLO would be lying, so I don’t.
  • The control plane is a single point of failure for the whole pipeline. If the e2-micro is down, every deploy wants to block on it. So it fails open — an unreachable control plane is a watchtron outage, not a service failure, and it won’t hold your deploy hostage unless you opt into strict mode.
  • Green still only means “passed a synthetic check.” It won’t catch a bug that only shows up under real user behavior. Strong signal, not Playwright.
  • And the one that got me: GitHub disables scheduled workflows after ~60 days of repo inactivity. The uptime monitor has a dirty keepalive for exactly this. watchtron’s cron.. did not. The new dashboard’s staleness flag is what surfaced it.

Try It

Source: github.com/swantron/watchtron

The heartbeat half: github.com/swantron/uptime-monitor

Live: watch.swantron.com · tronswan.com/status

It runs at $0 on free tiers, onboarding a service is a few lines of yaml, and a green checkmark means a bit more. Check it out ^

Shrinking third-party containers without touching a Dockerfile

Our ProdSec team at work recently leaned hard into Wiz and Slack-ops. Vulnerabilities started piling up across our various namespaces, a lot of them flagged critical on paper.. typical enterprise scenario where dashboards are red and every alert demands a response.

The catch: most of those images aren’t ours. We’re DevX, which sits inside CloudOps alongside Platform Engineering, SRE, and Cloud Cost.. a big part of what our org does is run the infrastructure that makes the clusters work. Wiz, Datadog agents, observability tooling, a bunch of GitOps stuff. Closed source, proprietary, not ours to rebuild. But they live in our namespaces, so we own the vuln count. Same for anything legacy or shared.. that comes as well.

Coworker bud and I started talking about what you’d actually do about this. You can’t patch a binary you don’t ship. You can’t swap the base image. The vendor updates when they update. The realistic options are: accept the noise, get exceptions granted, or get creative.

Moment of clarity: what if we just… axe the parts that aren’t running?

The Idea

A typical third-party agent image is built for maximum compatibility. It’s got shells, package managers, debug utilities, junk, man pages, and a bunch of shared libraries for features nobody in your environment uses. All of that adds up to hundreds of megabytes, and from a security scanner’s perspective, every one of those binaries is an attack surface.

Follow-up question: what does the container actually touch at runtime?

If you can answer that, you can rebuild the image from scratch with only those files. The binary still runs. The agent still pongs. But.. the shell that would give an attacker a foothold is gone. So is the package manager. So is most of the attack surface the scanner was complaining about.

So I Built Something

minifier-cli is a Go tool that does exactly this. Three steps:

1. Trace. You run your container through the tool and it watches which files the process actually opens and maps into memory — polling /proc/*/fd and /proc/*/maps inside the container every second. Everything that gets touched ends up in a log. This isn’t a real-time kernel stream, so a process that spawns, opens a file, and dies in under a second could theoretically be missed. In practice, that’s rarely an issue for the long-running agent processes this tool is designed for — and the ELF dependency pass in the next step catches the shared libs anyway.

minifier-cli trace start --image datadog/agent:latest --name dd-prod
# Let it run in a production-like environment
# Ctrl+C to stop tracing

2. Analyze (internal phase — no command to run). Before rebuilding, the tool parses every ELF binary in the trace log using Go’s debug/elf package — finding every shared library it imports, extracting the dynamic linker via PT_INTERP, and recursively resolving the full dependency tree. It also injects a safelist of files everything needs (passwd, group, hosts, resolv.conf) that processes rely on without explicitly opening. This analysis runs automatically inside the repackage step.

3. Repackage.

minifier-cli repackage --name dd-prod --output datadog-minimal:prod

The tool extracts Docker metadata from the original image (ENV, CMD, ENTRYPOINT, EXPOSE), copies the traced files via docker export tar streaming, generates a FROM scratch Dockerfile, and builds the final image. No manual file selection, no guessing, no rebuilding from source.

nginx:alpine goes from 91.7MB to 14.1MB. A startup-only trace of datadog/agent:latest (1.17GB) produces a 59MB image — and a longer, more thorough trace exercising all the agent features would land somewhere larger but still a fraction of the original. The attack surface shrinks proportionally — and the vuln scanner suddenly has a lot less to say. Now that’s a spicy meatball.

minifier-cli terminal output: trace, repackage, and docker images before and after

How It Ended Up

We eventually landed on a different solution for the actual hardening problem.. we are basically buying pre-hardened images that someone else has already performed surgery on. With vendor backing and security buy-off, we are somewhat less culpable.

But this approach holds up. The pattern is legitimate, the implementation works, and as a way to discover exactly what a black-box container actually needs at runtime, it’s genuinely useful — for hardening, for compliance audits, for understanding legacy applications you’re about to migrate, for cutting CI pull times.

It took a real problem to push me to actually build it. Open-sourcing it now on the theory that someone else has the same problem.

The Catch

Your minified image contains exactly what was accessed during the trace — nothing more. That’s the point, but it’s also the risk.

Error-handling code that only fires under specific conditions won’t get traced during a happy-path run. Plugins loaded by name at runtime won’t show up unless that feature was exercised. A database driver that only activates for a certain config flag won’t be there if you didn’t trigger that path. And if your agent only calls out to a third-party webhook when a network timeout occurs, the lib handling that request won’t be in your minified image unless you deliberately induced a timeout during the trace.

The playbook: so.. run your full integration test suite while tracing. Write your integration test suite if you don’t have one.. rinse and repeat. Hit ya feature flags. Hit your feature flags. Trigger your error states. If the application has a warm-up or health-check mode, run those too. The more representative your trace workload, the more complete the image.

Then validate the minified image thoroughly in staging before it goes anywhere near production. Keep the original around as a fallback. Normal SRE stuff. Don’t ship it on the strength of a five-minute trace and a curl localhost.

Enjoy

git clone https://github.com/swantron/minifier-cli
cd minifier-cli
go build -o minifier-cli .

Pre-built binaries for Linux amd64/arm64 and macOS amd64/arm64 on the releases page. The tracer requires Linux (it reads /proc inside the container), but the repackager runs fine on macOS against a remote Docker daemon.

Source: github.com/swantron/minifier-cli

The Covid Bike: 3,500 Rides Later

I wasn’t a spin dude before Covid, and I still don’t really claim to be now. In 2020, the world sort of shut down, I went remote and lost my gym at Workiva. Katie bought us a Peloton early in the pandemic, and we’ve spent the last six years trying to break the thing.

We finally succeeded. I guess we’re spin dudes.

The resistance started jumping around and acting erratic a few weeks ago. Peloton support tried to tell me I needed a technician to install a new monitor cable. No on the tech, and sensor cable in the mail. I got ansty and contacted support the following day, and was able to talk them into selling me the sensor board.. again declining the tech after repeatedly acknowledging I would void my five year lapsed warranty.

Peloton resistance sensor board and cable assembly

Some Stats

I was curious what the “odometer” actually looked like after six years. Since this is an early bike, I had to manually pull the numbers from our profiles:

  • jswan: 1,825 rides + 209 Bootcamps
  • kt: 1,505 rides
  • Total: 3,539 sessions

I do enough 45-minute rides to offset the short 10-minute add-ons, so this hardware has easily seen 2,000+ hours of work. Honestly, one sensor board failure after that much volume is less of a defect and more of a badge of honor.

The Swapperoo

Two items..

Peloton order history showing monitor cable April 9 and sensor assembly April 10

The swap is dead simple. Remove the sweat guard, untuck wires, remove the sensor, replace the sensor, tuck wires, CALIBRATE, replace sweat guard. Phillips screwdriver and a hex set. Navigating to the calibration mode using the wonky Peloton UI arguably took me more time than the swap.

A Few Takeaways

If you have an old Original bike and you’re actually putting miles on it:

  • Don’t bite on the tier 1 support advice: If the resistance is jumpy but the screen works fine, it’s the sensor board. Skip the monitor cable.
  • Recalibrate: New sensors mean the bike might feel “heavy” or “light” compared to your old PRs. Run a calibration kit to level it out.
  • Check Pedals: We are pushing it with 3,500 rides, the bearings are still decent but on borrowed time.

The bike is a beat-to-shit but back in the rotation. It’s a tank..

242

Super Mario Galaxy 2 dropped a billion years ago. Simpler times. The Wii was so much fun.. Sunshine was perfect and somehow Galaxy was better. Galaxy 2 was not better for me.. it was a game I dropped and I do not do that. I think I got around 80 stars and dropped it. I don’t quit books and finish the games I go in on. Weird stuff.. life happens.

My gaming setup is pretty straight forward. I’m a Nintendo guy, exclusively. We play family stuff docked, and I roll through Zelda and Mario stuff when I travel or when I need a break from projects. I have a short list of games I replay.. OoT on the 3DS, 3D All-Stars, BotW, TotK. I’ve had stints with Splatoon and Red Dead, but it is mostly Zelda and Mario. I beat the shit out of my Switch Lite between work travel to the Midwest, work from home acclimation, and then again on work trips to the Bay. It is my walking the dog mental reset after the dog is walked, I suppose

242 stars

Here we are in 2026. Katie bought me a switch before my last onsite, so I’ve done my lap through the regulars. Odyssey as well.. it is better after some time away. Nintendo definitely had me profiled with the Switch version of Galaxy 1 / 2.. I was 30 stars into Galaxy 2 when it dropped, so did a quick pivot and started on the new console (handheld, obvs.) I did the main runthrough, and picked up Galaxy 2 out of muscle memory. It finally clicked.

Maybe the second playthrough of Odyssey had me primed for a grind, or maybe I’m just older and more patient. Galaxy 2 makes it super easy to be directly in the middle of a challenging platform situation, but not feel frustrated. The levels are fun, so it isn’t a pain in the ass. The final stars were honestly brutal—it was an endurance test.

Long story short, it is a 10/10. Best platformer I’ve played.. it took me 16 years to finish, but worth it.

Wrenchtron: DIY Vehicle Maintenance Tracker

Kendall Ford in Bozeman books out three months. I have a 2021 F-150 under powertrain warranty.

Those two facts are incompatible, so I’ve just started doing everything myself.

I was already servicing most of the fleet at home—RZR, old wheeler, mower on its last leg, a snowblower. DIY is usually faster and cheaper, and to be honest fun. The warranty situation just made it urgent to actually document everything, and a glovebox full of Costco receipts wasn’t going to cut it.

So I built Wrenchtron.

2017 F-150 Platinum — service status and maintenance history

The Problem

Nothing out there handles a mixed fleet well. Apps for car guys assume you’re running a shop. Spreadsheets fall apart once you have a few vehicles on different schedules, and I’m not about to use a spreadsheet in the first place. OBD apps don’t know what a snowblower is. Etc etc..

I’m running Kirkland 5W-30 and buying OEM filters at the parts counter—best of both worlds for the warranty. But I need those receipts attached to a log entry to make any of it mean anything.

The Solution

Wrenchtron tracks service history across whatever fleet you’ve got. Photos, receipts, costs, schedules—and it works offline. Installs on your phone like a native app.

Features that matter:

  • Five interval types — mileage, time, seasonal, calendar month, or composite. An oil change at 5k miles or 6 months triggers whichever comes first
  • Maintenance Hub — one screen showing overdue, due soon, and upcoming across every vehicle
  • Projected mileage — estimates when you’ll hit the next threshold based on your annual mileage rate
  • NHTSA recall integration — pulls open safety recalls by VIN automatically
  • Receipt photos — attach images to any log entry
  • Typed service records — oil changes track weight, brand, and filter; tires track position and tread; brakes track pad type and rotors. Structured, not freeform
  • Cost tracking — per-entry cost fields, stored in cents to avoid floating point nonsense
  • PWA with offline support — Firestore syncs via IndexedDB so the data is there without a connection

Maintenance Hub — cross-fleet schedule at a glance

The Stack

Next.js 15 static export, Tailwind CSS v4, Firebase for auth, Firestore, and Storage, with @serwist/next handling the PWA layer. No server, no API routes—everything runs in the browser, security lives entirely in Firestore rules. GitHub Actions runs tests on push and deploys to Firebase Hosting. Pipeline is a few minutes start to finish.

The garage

Check it out

Live: https://wrenchtron.com

Demo (no login): https://wrenchtron.com/demo

Source: https://github.com/swantron/wrenchtron

Do your own work, document it properly, skip the three-month wait. Wrench cheers..

The Midi

NYT_Games: check it out.. new daily crossword.. the ‘Midi!’

NYT Games - The Midi

jswanson: welp, there goes another four minutes of my day

Completed Midi puzzle grid

NYT_Games: huzzah!

Solved the Midi in 2:15

jswanson: s/four/slightly over two/

Self-Hosting a Bluesky PDS

I’ve been sitting on a Bluesky Personal Data Server (PDS) for a few months now. Why? Great question.. I guess because a few of my privacy-nerd friends were asking if anyone had tried to set one up. I like setting stuff up so I did, and am finally getting around to documenting the thing. I put together a guide for others who want to do something similar: bluesky-pds-guide.

Why the AT Protocol?

The AT Protocol (Authenticated Transfer Protocol) was created by the Bluesky team. When you use Twitter or Facebook, your data lives on their servers. You’re locked in. A platform changes policies, gets acquired, shuts down—you lose everything.

The AT Protocol flips this. Instead of one company owning everyone’s data, you can run your own Personal Data Server (PDS). Your posts, follows, and media live on your server. You can move between PDS providers, or run your own, without losing your identity.

It’s basically how email works. You can have Gmail or run your own mail server, but you can still email anyone. The AT Protocol brings that same interoperability to social media. It’s the antidote to the ‘walled garden’ junk we’ve been dealing with for a decade. Bluesky started sidling away from their open stance a bit, my friends got nervous, so here we are.

My Setup: jswan.dev on Digital Ocean

I set up a PDS at jswan.dev. Running:

ComponentChoice
HostDigital Ocean ($6/mo)
Domainjswan.dev
EngineDocker + Caddy
StorageSQLite + Local Disk

Setup was straightforward. The official Bluesky installer handles most of it—sets up Docker, configures Caddy for TLS certificates, gets everything running. Main work was configuring DNS records (A record for root domain, wildcard for subdomains) and running through installer prompts.

Once it was up, I created an account: @com.jswan.dev. Handle format is username.yourdomain.com.

The Guide

After going through the setup, I realized there wasn’t a decent guide that walked through everything start to finish. So I made one.

It covers:

  • Prerequisites (domain, VPS, email service)
  • Setting up a Digital Ocean droplet
  • DNS configuration for multiple providers (Squarespace, Namecheap, Cloudflare, GoDaddy)
  • Step-by-step installation
  • Email/SMTP setup
  • Maintenance and updates
  • Troubleshooting common issues

Written for people who are technical but maybe haven’t self-hosted much.

The Reality: I Don’t Really Use It

I have a Bluesky account at @com.jswan.dev, and I’ve set up this whole infrastructure, but I’m not really posting on social media. Not my thing.

But that’s fine. The point wasn’t necessarily to become an active Bluesky user. The point was learning how federated protocols work, understanding how to set up and maintain a service, and having the infrastructure if I want it. If friends want accounts, I can give them invites. The data lives on my server, even if that data is currently just me posting a sweet link to a swantron blog post once every three months.

Creating the guide was valuable—forced me to think through the process clearly and make it reproducible. Hopefully it saves somebody debug time.

The Cost

Running your own PDS:

  • Domain: $10-15/year (if you don’t already have one)
  • VPS: $6-12/month (DO is my go-to but there are a lot of options)
  • Email: Free tier available (Resend, SendGrid) but I still use GSuite

Total: around $100/year.

If you’re interested in setting up your own PDS, check out the guide: github.com/swantron/bluesky-pds-guide.

Self-host your identity. It’s cheap and probably a good thing to do.

WordPress to Hugo Migration

This is the first time in decades I haven’t had a WordPress instance live. Lordamercy…

Deleting WordPress site - final step

WP Trucker Logs: From Shared Hosting to Code-First

Over two decades, swantron.com hopped through several hosting trends:

  • Phase 1: Classic – Traditional shared hosting (Siteground)
  • Phase 2: Cloud Ops – VMs & DBs (EC2 & RDS / GAE & not RDS)
  • Phase 3: IaC-adjacent – Dockerized one-click pets (GCP, but mostly DigitalOcean)
  • Phase 4: The End State – Static delivery via Hugo + GitHub Actions

Each previous phase was just a different way of babysitting a server. This migration is different. It’s not just a new host; it’s a fundamental change in philosophy from ‘managed system’ to code-first delivery.

The Database Horror (The Lordamercy)

I had a shower thought about how gross a WordPress database might end up after being left out in the rain for 20 years (2005-2025). I finally checked. My final database dump: 34MB and 555,947 lines of text.

SQL file size - 555,947 lines

  • 122,307 references to bouncerblog.com—a domain that died over a decade ago.
  • Serialized PHP arrays stored as strings. Want to change a simple rewrite rule? Nope. Good luck parsing a 2,000-character string in wp_options.
  • Zombie Data: Thousands of _transient entries and orphaned plugin settings that WordPress autoloads on every single page request, long after the plugins are deleted.

The database had become a dumpster fire. Transitioning to Markdown files is nice. No more regex-searching a SQL dump just to find a setting.

The Migration (The 1,040 Post-Slugs)

Moving 1,040 posts is a special kind of hell. The goal was to strip away the ugly legacy /index.php/ prefix from my URLs without breaking 20 years of external links and search indexing.

I wrote some dirty Python to automate the alias field in Hugo’s spec to map the shitty legacy paths to the new clean ones.

The result in each Markdown file:

title: "This Old Post about Hot Sauce"
slug: "this-old-post-about-hot-sauce"
aliases:
  - /index.php/2005/10/10/this-old-post-about-hot-sauce/

Alias example in frontmatter

Super straightforward. Hugo generates redirect HTML pages at the old paths during the build. This preserves every bookmark (yeah right), share, and search result while allowing the site to live at a modern URL.

Result: 1,041/1,041 posts migrated with 100% link integrity.

The New Stack

Hugo 0.154.5 for static generation, GitHub Pages + Actions for hosting and CI/CD. No themes—just custom CSS and layout code that I control entirely. No comments, because I’m not collecting feedback from blog commentators.

The Tipping Point

FeatureWordPressHugo
Speed2s - 4s Load< 500ms
SecurityConstant PatchesZero Attack Surface
CostMonthly Fees$0 (GitHub Pages)
URL Structure/index.php/slugSane
ContentMySQLMarkdown / Git

Why This Matters

I write Markdown, I git push, and it’s live.

It feels like the OG blogging days again. We had CMS shit back then, but blogging was largely just writing and publishing. Simple and direct. SEO made things weird for a bit (paid posts… did that) and we tried to mash PHP junk onto all sorts of places for no particular reason. The CMS never really got better, and blogs sort of died under their own weight.

In a way, I’m jumping back in with the terminal Gs who never bothered down this path in the first place. They’ve been over there in their minimal setups, posting random shit about obsolete tech this entire time, while the rest of us were fighting database corruption.

Cowbot - The migration buckaroo

Here’s to another decade, though… this time it is static, versioned, and finally cattle, not pets.

vinnila

12 year old provided bath bomb to 14 year old

(12 year old is in the ET program and was the 6th grade spelling bee runner up)