My First Rust: Rewriting My Blog's Backend
A Go developer ships a first real Rust service: the axum + sqlx API behind fanzru.dev. What clicked, what hurt, and what changed in how I write code.
#rust #axum #sqlx #backend
I write Go for a living. This week I shipped my first real Rust service: the API behind this website.
TL;DR
- The whole backend of fanzru.dev (CMS, content, view counts, analytics, GitHub stats, 1on1 booking) is now Rust: axum + sqlx + Postgres.
- About 2,600 lines, built in three days, deployed with Docker Swarm on my homelab.
- The compiler is strict, but almost every time it complained, it was right.
- Biggest mindset shift coming from Go: errors and inputs are types, not
if err != nil.
Why Rust, why now
My blog used to be Markdown files in the repo. Every typo fix meant a commit and a redeploy. I wanted a small CMS: log in, edit, save, and the page updates right away.
That is a perfect "first project" size. A few tables, a handful of endpoints, real users (me), and real production traffic. Small enough to finish, big enough to hit the hard parts.
I could have written it in Go in an afternoon. That was exactly the reason not to.
The stack
| Piece | Crate |
|---|---|
| HTTP | axum 0.8 |
| Database | sqlx 0.9 (Postgres, migrations) |
| Async runtime | tokio |
| Passwords | argon2 |
| HTTP client | reqwest (rustls) |
| Errors | anyhow + my own AppError |
When I save a post in the CMS, the Rust API calls back into Next.js to revalidate that page. No rebuild, no waiting.
Lesson 1: errors are just a type
In Go, every handler ends up like this:
post, err := repo.Get(ctx, slug)
if err != nil {
http.Error(w, "internal error", 500)
return
}
In Rust, a handler returns Result<T, AppError>, and ? does the boring part:
#[derive(Debug)]
pub enum AppError {
BadRequest(Cow<'static, str>),
Unauthorized,
NotFound,
Conflict(&'static str),
TooManyRequests,
Internal(anyhow::Error),
}
async fn get_post(/* ... */) -> Result<Json<Entry>, AppError> {
let entry = repo::find(&db, slug).await?.ok_or(AppError::NotFound)?;
Ok(Json(entry))
}
AppError implements IntoResponse, so one match decides the status code for the whole API. Every error looks the same: {"error": "..."}. Internal errors get logged and the client only sees "internal server error".
Why I like it: I can't forget to handle an error. If a function can fail, the type says so.
Lesson 2: validate input before the handler runs
axum has extractors: handler parameters that axum fills from the request. I wrapped the built-in ones so bad input always becomes a clean 400:
#[derive(FromRequest)]
#[from_request(via(axum::Json), rejection(AppError))]
pub struct JsonBody<T>(pub T);
Now a handler like book(JsonBody(input): JsonBody<BookInput>) only runs if the JSON already matches BookInput. No manual decoding, no half-parsed structs.
Lesson 3: shared state needs a plan
In Go, I'd put a cache in a struct with a sync.Mutex and move on. Rust makes you say who owns it and who can touch it:
pub struct Booking {
google: Google,
busy: Mutex<Option<(Instant, Busy)>>, // cached calendar, 60s
lock: Mutex<()>, // one booking at a time
}
Wrapped in an Arc, shared by every request. The compiler simply refuses to let two requests mutate the cache without the lock. Race conditions that I would normally find in production are compile errors here.
Lesson 4: clippy is a strict senior reviewer
I turned on clippy::pedantic and CI runs it with -D warnings. It caught real things: structs declared in the middle of a function, needless clones, casts that could truncate.
It also humbled me once. My deploy failed because CI had a newer Rust (1.100) than my laptop, with a new lint:
// before: clippy::map_unwrap_or
.map(|days| ...).unwrap_or_default()
// after
.map_or_default(|days| ...)
Lesson learned: keep your local toolchain on the same version as CI (rustup update).
Lesson 5: one binary, many jobs
The same binary is the web server and a small CLI:
fanzru-dev-api # run the API
fanzru-dev-api create-user … # create or reset a CMS user
fanzru-dev-api refresh-github # fetch GitHub stats, save, revalidate
A daily cron runs refresh-github in a one-off container. The GitHub token only lives in that process, never in the running service. The image is tiny (lto = "thin", strip = true) and it starts instantly.
What was hard
- Lifetimes showed up the moment I tried to borrow a string into a struct. Most of the time the fix was "just own it" (
Stringinstead of&str). - Compile times are real. A warm cache helps a lot.
- Choosing crates. Go's standard library covers almost everything; in Rust you pick an ecosystem (tokio, axum, sqlx) and learn how they fit together.
What surprised me
- When it compiles, it usually just works. The GitHub stats refresh ran correctly on its very first production run.
sqlxmigrations run at startup, so a deploy is just "push tomain".- Writing Rust made me write better Go. I now think about who owns data, even when Go doesn't force me to.
Would I do it again?
Yes. For a small CRUD API, Go is still faster to write. But Rust gave me something Go doesn't: confidence that the boring mistakes are already gone before I deploy.
The words you are reading right now came from that API. Not bad for a first Rust project.
Stack: axum · sqlx · tokio · Postgres · Docker Swarm · Next.js 16
