Blog
Tech6 min read1 view

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:

go
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:

rust
#[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:

rust
#[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:

rust
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:

rust
// 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:

text
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" (String instead 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.
  • sqlx migrations run at startup, so a deploy is just "push to main".
  • 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

Share To Friends