Paul BecotteAdmin

Rewriting the Blog (Again)

Rust, a Homelab, and a Lot of Help From Claude

If you've been reading this site for a while (hi Mom), you know the pattern by now. Every few years I get the itch to learn something new, and the blog is where I scratch it. The first version was Flask, because that's what we used at work. Then I bolted on an Angular2 frontend because I didn't know any Javascript. Then I rewrote that in React (because I didn't know React, and also had built a whole Draft.js editor I was weirdly proud of). Each time, the actual point wasn't really the blog- it was a side project small enough to finish that forced me to learn the thing.

This time the thing is Rust. I have a bigger project coming up that I want to write in Rust (more on that some other time), and I didn't want my first real Rust code to be the important stuff. So- the blog gets rewritten again. As usual, I got a little carried away and also rebuilt where it runs. And I tried something new this time: most of the code was written by an AI, with me steering. Let me walk through all three.

The Stack

The old site was a Flask/SQLAlchemy API with a separate React app in front of it, deployed to AWS on EKS via Terraform. It worked fine. But it was two codebases, two build pipelines, and a Kubernetes cluster costing real money every month for a blog that gets... let's say "modest" traffic lol.

The new one is Rust from top to bottom:

  • Leptos for the frontend, running on Axum for the server
  • Postgres through sqlx
  • Markdown for post content, rendered with pulldown-cmark
  • Images go to S3, same as they always have

The fun part here is Leptos. It does server side rendering AND compiles the same components to WebAssembly that "hydrate" in the browser. So a crawler (or you, on a slow connection) gets real HTML on the first request, and then the page becomes a live app once the WASM loads. One codebase, one language, no separate API for the frontend to call. The way it handles data is "server functions"- you write a normal-looking async function, mark it #[server], and Leptos generates the HTTP endpoint and the client side call for you-

#[server]
pub async fn list_posts(category: Option<String>) -> Result<Vec<PostStub>, ServerFnError> {
    use sqlx::PgPool;

    let pool = expect_context::<PgPool>();
    let posts = sqlx::query_as::<_, PostStub>(
        r#"
        select p.id, p.title, p.slug, p.tagline, p.published, p.posted_at,
               i.s3_url as header_image_url, p.category
        from posts p
        left join images i on i.id = p.header_image_id
        where p.published and ($1::text is null or p.category = $1)
        order by p.posted_at desc
        "#,
    )
    .bind(category)
    .fetch_all(&pool)
    .await
    .map_err(|e| ServerFnError::new(e.to_string()))?;
    Ok(posts)
}

On the server, that runs the query. In the browser, the exact same call becomes a fetch to the server. That use sqlx::PgPool living inside the function body is because the WASM build doesn't have sqlx at all- it's behind a cargo feature flag that only the server build turns on. That took me a minute to get used to.

The one thing I gave up was the WYSIWYG editor. There just isn't a mature rich text editor in the Rust/WASM world yet, so posts are Markdown now, with a live preview in the admin page that uses the same render function on both sides. Honestly? I don't miss it. I am a developer, I write Markdown all day anyway. (RIP to all that Draft.js code though). It did mean that moving the old posts over needed a real conversion step instead of just copying the database (more on that below).

Login is just me- there's one admin, and it's my GitHub account. Rather than build any auth myself, the blog does an OpenID Connect login against a Dex instance I already had running for ArgoCD. Which brings me to...

The Homelab

I wanted to stop paying for EKS. I also, if I'm being honest, just wanted a homelab. So the site now runs on a two node k3s cluster:

  • One node is a VM on Hyper-V on my desktop at home, which does the actual work
  • The other is a tiny ARM EC2 instance (a t4g.micro!) with an Elastic IP, which is the front door. It runs the ingress and holds the AWS permissions for DNS and certificates

The two nodes talk over Tailscale, so the home machine never has a port open to the internet. Everything about the cluster itself is defined in Terraform, from the Hyper-V VM (yes, there is a Terraform provider for Hyper-V, and yes, it has opinions) to the EC2 node, S3 buckets, IAM users, and secrets.

Everything on the cluster is GitOps. Yes, I know I wrote a post called #GitOps is an Anti-Pattern. I still think it's the wrong way to promote application builds between environments! But for "what platform stuff is installed on my one cluster", a repo that ArgoCD keeps in sync is pretty great. The pieces:

  • cert-manager and external-dns, so adding an Ingress with a hostname gets real DNS in Route53 and a Let's Encrypt certificate with no further work
  • external-secrets, pulling secrets out of AWS Secrets Manager so nothing sensitive lives in git
  • k8s-monitoring, shipping metrics and logs to Grafana Cloud (I wrote about setting that up last year)
  • ArgoCD itself, with its bundled Dex doing GitHub login for both ArgoCD and the blog

Getting the cluster up was, by far, the most frustrating part of the whole project. My favorite: the VM booted fine but none of my cloud-init config ever ran. No SSH key, no Tailscale, nothing. It turned out that Windows was writing the cloud-init seed disk as a pure UDF filesystem instead of ISO9660, and cloud-init just silently ignored it. That one took days. Another- public DNS for the cluster was quietly broken for days because I had told k3s the EC2 node's "external IP" was its Tailscale address, so external-dns faithfully published a private IP to the whole internet lol.

The AI Part

Here's what was really different this time. I didn't write most of this code. Claude Code did, and I mostly drove it from my phone.

That sounds lazier than it was. My job shifted from typing to making decisions and reviewing. Leptos or Yew? (Leptos, I want server rendering.) Separate repo, or put it in my monorepo? (Monorepo.) Should the blog borrow the EC2 node's AWS credentials for S3 uploads? (No- give it its own scoped IAM user, I don't want the blog pinned to one node just to get credentials.) Comments? (Not for now- that means public signups and spam, and I said in 2015 that I'd have to be careful about that lol.) I'd lay out what I wanted, it would come back with a plan, we'd argue about the plan a bit, and then it would go build it.

Some observations-

Data migration is where it really shines. Honestly, this is probably the single best use I found for it. Ten years of posts, stored in Draft.js's own JSON format (blocks, inline style ranges, entity maps for the images, code blocks with syntax metadata hanging off them), all needed to become clean Markdown in a different database. That is exactly the kind of job I would normally put off for months- not hard, exactly, just a long tail of fiddly edge cases, where you write a script, eyeball the output, find the post where the italics are off by three characters, and go around again. Here it was basically "move the old posts over," followed by some spot checking. The images that I had sized in the old editor even came across at the right sizes. The part I always assumed would be the slog of the project was one of the easiest.

"It compiles" is not "it works". In Rust especially, cargo check passing feels like it means something. It doesn't, at least not for anything that touches the network. The login flow compiled perfectly and then panicked on startup with scheme is not http. The cause was that turning off an openidconnect crate's default features also quietly turned off its TLS support, so it had no way to make an HTTPS call. The AI got a lot better once the rule was "run it against the real thing before calling it done."

It's good at the tedious stuff I would have skipped. RSS feed, sitemap, SEO meta tags, an admin page to delete old images- all things I would have put on the "later" list forever. When writing the code costs a sentence, "later" turns into "sure, why not."

It needs notes. Every session starts from scratch, so the project carries notes- what we decided, what broke, what the weird gotchas are (there are a LOT of weird gotchas in a homelab). Writing down "we tried X, it failed because Y" turns out to be just as useful for the AI as it ever was for human teammates.

It runs into the same walls you do. My dev environment has a messed up mix of Nix and system libraries that broke compiling any Rust macro that links C code. The AI didn't magically fix that- it did what I would have done, which is work around it (hand-writing the sqlx::FromRow impls instead of using the derive macro). It just did it a lot faster than I would have.

Conclusion

So here we are- version four, I think? Same posts, a nicer look, a whole cluster underneath it, and a monthly AWS bill that's now a fraction of what it was (I finally turned off the old EKS cluster while writing this post).

So, did I learn Rust? Nope lol. I can follow it, and I have plenty of opinions about how this app is put together, but I couldn't sit down and write it from scratch. Given that "learn Rust" was the entire reason I started, that should probably bother me more than it does. I'm honestly undecided on whether it matters.

Both at work and on side projects, I have been spending more and more of my time on architecture and supervision, and less on actually writing code. On Python projects, where I do know what I like, I get a lot more involved- usually when the AI does something wrong, or does it in a way I don't prefer. But I'm not sure my way is actually better. A lot of what we call "clean code" exists to make things easier for the next human who has to read it. If humans aren't reading the code long term, it's quite possible a lot of that just doesn't matter anymore. What still matters is that the architecture is sound, and that it actually works when you run it against the real thing. I think it's very possible that this is the way most software will be written in the years to come.

One last thing. My old Introducing DevBlog post said my biggest problem with side projects is getting carried away with deployment automation and never actually getting to the project. This time I got carried away building an entire homelab... and the blog got done anyway. I'm calling that progress.

Next up is the bigger Rust project I mentioned. I guess we'll find out whether I need to know Rust to build it lol. More on that soon!