How I ended up building this blog instead of just using WordPress

People keep asking why I didn't just use WordPress, or Ghost, or honestly any of the dozen platforms that would have had me publishing a first post within the hour instead of within the year. The short answer is I wanted to learn something, and the long answer is basically this whole post.

what I actually tried first

I did look at the obvious options before deciding not to use them. WordPress felt like the wrong tool for what I actually wanted, a lot of plugin surface area for things I didn't need, and a PHP stack I had zero interest in learning just to run a blog. Ghost was closer, genuinely nice out of the box, but it's still someone else's platform underneath, and the entire point of this project was never really "have a blog," it was "build something real enough that I couldn't fake my way through it." A hosted platform doesn't ask you to understand how logins work, or how uploads get stored, or what happens when two hundred people hit the same page at once. I wanted the thing that asks those questions, not the thing that answers them for me before I even think to ask.

I'd been picking up bits of Go and SvelteKit for a while at that point, mostly through small scripts and half-finished tutorial projects, and none of it felt like it added up to anything real. A blog seemed like a good excuse. Small enough to actually finish, with enough real pieces (accounts, a database, something public-facing) that I couldn't fake my way through it. So instead of installing something that already worked, I decided to build the thing that would teach me the most, even knowing it would take a lot longer.

the shape it actually turned into

Five services, one docker compose file, running on a single box in my flat. None of that was the plan on day one. It's just where a lot of small decisions, most of them made because the previous approach broke, ended up.

services:
  postgres:
  auth:
  blog:
  frontend:
  caddy:

why Go and SvelteKit specifically, and not something more popular

Neither choice was really about picking "the best" tool. Go, because I wanted something compiled and boring in the good way, a language that doesn't have fifteen different accepted ways to structure a small web service, and because the standard library alone gets you most of the way to a working HTTP server without reaching for a framework by default. SvelteKit, mostly because it was the newest thing on my list at the time and I wanted the frontend half of this project to also be a learning project, not just a place to bolt a familiar tool onto the two Go services. Django or Express would have gotten me to a working site faster, both languages I already knew reasonably well going in. Faster wasn't really the goal here.

Docker compose over anything more elaborate was a similarly unglamorous decision. This runs on one box. Kubernetes solves problems I don't have, coordinating many machines, rolling out zero-downtime deploys across a fleet, none of which apply to a single server sitting in my flat. A compose file I can read start to finish in under a minute beat a more "proper" setup that would have taken weeks to learn for no actual benefit at this scale.

the auth service, in plain words

One small Go service, one job: know who you are, and prove it to everything else without anyone else having to trust a password directly. Register, log in, get a short-lived access token plus a longer-lived refresh cookie, so you stay logged in without the access token itself living forever if it ever leaked.

func (s *Server) Login(w http.ResponseWriter, r *http.Request) {
	var req loginRequest
	if err := decodeJSON(w, r, &req); err != nil {
		writeDecodeError(w, r, err)
		return
	}

	user, err := s.Queries.GetUserByUsername(r.Context(), req.Username)
	if err != nil {
		writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
		return
	}

	ok, _ := password.Verify(req.Password, user.PasswordHash)
	if !ok {
		writeError(w, r, http.StatusUnauthorized, "invalid_credentials", "invalid username or password")
		return
	}

	s.issueTokensAndRespond(w, r, user, http.StatusOK)
}

Passwords never get stored directly, obviously, just a hash, and a specific hash designed to be slow on purpose (argon2id), so that even if the database ever leaked, brute-forcing it back into real passwords is expensive enough to not be worth trying at scale.

the blog service, in plain words

The second Go service, a completely separate one, on its own database, owns posts, comments, tags, categories, media metadata. It doesn't know anything about passwords itself, it just trusts a token the auth service issued, the same way a bouncer trusts a wristband without needing to personally verify your ID again at every door inside the venue.

r.Route("/admin/posts", func(r chi.Router) {
	r.Use(authmw.RequireCapability(verifier, matrix, capability.PostsManage))
	r.Get("/", s.AdminListPosts)
	r.Post("/", s.AdminCreatePost)
	r.Patch("/{id}", s.AdminUpdatePost)
})

Keeping this as a second, separate service from auth felt like overkill at the start, extra network hops for no obvious reason, right up until I actually wanted to reason about "what can go wrong with the accounts system" completely separately from "what can go wrong with the posts system." Two smaller, boring problems instead of one big scary one.

the SvelteKit frontend, in plain words

This is the part anyone visiting the site actually sees, server-rendered pages, a login form, the post editor, all of it. It doesn't talk to the two Go services directly from the browser, it proxies everything through its own server-side routes first, which turned out to matter a lot more than I expected once I actually got to the reverse-proxy and rate-limiting struggles further down.

async function handle(event: RequestEvent): Promise<Response> {
	const upstreamPath = event.url.pathname.replace(/^\/api/, '') || '/';
	const target = new URL(upstreamPath + event.url.search, env.BLOG_INTERNAL_URL);
	const upstream = await forwardRequest(event, target, extraHeaders);
	return new Response(upstream.body, { status: upstream.status });
}

postgres, in plain words

Two separate databases, one for auth, one for the blog, not one shared schema with everything mixed together. It felt like more setup for no reason at first, and then felt obviously correct the first time I wanted to back up or migrate one without touching the other at all.

minio, in plain words

Uploaded images and avatars don't live inside Postgres, they live in MinIO, which speaks the same API as Amazon S3 but runs as just another container on my own box. I liked this specifically because it means switching to a real cloud bucket later, if I ever needed to, is a configuration change and not a rewrite. Whether I'll ever actually need that is a separate question. Having the option cost almost nothing up front.

caddy, in plain words

The only thing in the whole stack with a public port. Everything else only talks to everything else over the internal docker network, nothing reachable directly from outside except through Caddy. It terminates TLS, so I never have to think about certificate renewal, and it's the thing that actually enforces every size limit and rate limit the rest of this post is about to complain about getting wrong the first time.

handle /api/admin/* {
	request_body {
		max_size 5MB
	}
	reverse_proxy frontend:3000
}

That's roughly the shape of it. Now the parts that actually went wrong, each one worth its own honest section instead of a single vague "and then I fixed some bugs" paragraph.

the upload size-cap saga

Uploads were the first real wall. I could upload a small test image fine, then tried a real photo straight off my phone and got a cryptic error with no useful message at all, just a failed request with nothing helpful in the browser console. Turned out there's a request size cap sitting in Caddy in front of the app's own limit, and I'd set it way too small without really understanding that two separate layers both needed to agree on what "too big" means.

# before: media uploads fell under the same small default as everything else
handle /api/admin/* {
	request_body {
		max_size 2MB
	}
	reverse_proxy frontend:3000
}

Caddy's own default is nowhere near generous enough for a modern phone photo, and until I actually went looking, I had no idea it was even a separate limit from whatever the Go service itself enforced.

The upload path, from the admin form through Caddy to storage

Took an evening of confused staring at logs before I even found where the limit lived, let alone why. Half the fix came from a five year old forum thread, not the docs, someone describing the exact same symptom on a completely unrelated project, which is how I actually figured out to go looking at Caddy's config instead of assuming the bug lived in my own Go code the whole time.

handle /api/admin/media* {
	request_body {
		max_size 1100MB
	}
	reverse_proxy frontend:3000
}

A separate, much larger limit just for the media upload path specifically, everything else in the app staying on the smaller default. Once both layers actually agreed with each other, real photos started uploading without complaint.

comments and login taking forever, longer than I expected

I think I assumed "user types password, server checks it" was basically the whole feature. There's actually a surprising amount underneath that: hashing the password properly, issuing a short-lived token plus a longer-lived one for staying logged in, and then the part that actually got me, rate limiting behind a reverse proxy. Comments turned out to have their own smaller version of the same lesson, a comment form looks trivial until you're also handling spam, rate limits per user, and moderation, none of which show up in a tutorial's "add a comment box" example.

learning what a reverse proxy actually does

Every login attempt looked like it came from the same IP, because Caddy was the only thing ever talking to my app directly, and I hadn't told it to trust the header that actually says who the real visitor was. That one took a long time to even understand as a bug, since the symptom looked like "rate limiting is broken" rather than "I don't actually understand what a reverse proxy does to a request." A reverse proxy is one of those terms I'd used constantly for years without ever really knowing what it did mechanically, and building this thing made me actually sit down and figure it out instead of nodding along the next time someone mentioned one.

Roughly how login actually works: a short-lived token plus a longer-lived one

rate limiting biting me specifically

Once I understood the reverse-proxy problem, the actual fix was small, trust exactly one hop of the forwarded-for header, matching the one proxy I actually have in front of the app. But getting there meant first noticing that ten failed login attempts from ten different real people were somehow all landing in the same bucket, getting suspicious that something was fundamentally wrong with how I was identifying who a request was even from, and only then realizing the request's apparent source IP was Caddy's own container address every single time, for every visitor, because Caddy really is the only thing that ever opens a socket to the frontend. Every visitor sharing one identity, from the app's point of view, is about as broken as rate limiting can get without throwing an actual error to tell you so.

the redesign, generic first, then actually Lovund

The first real version of this site's design was, looking back, a blog template with the serial numbers filed off. Blue-ish accent, default system font, spacing that looked like every other quick site anyone builds in a weekend. It worked. It also could have been anyone's blog, which started bothering me more the longer I looked at it, once the actual functionality was solid enough that the design was the thing left standing out as unfinished.

A few weeks ago I redid the whole visual side. Green accent color, a serif for the body text, tighter margins than the default I'd started with. Not because the old version was broken. It looked like every other quick blog template, and I wanted something that felt like an actual choice instead of whatever a starter kit shipped with. (I also said I wouldn't touch the CSS again once the redesign shipped. That lasted about four days.)

The green specifically wasn't random. Living somewhere as green as Lovund gets in summer, moss and hillside and the water doing that particular deep color it does under an overcast sky, it felt like the accent this site should actually have had from the start instead of whatever generic blue-adjacent color I'd defaulted to without thinking about it at all. Once I picked that, the rest of the redesign mostly followed from making everything else quiet enough that the green actually stood out instead of competing with six other colors for attention.

a small feature I only added afterward

One thing the blog service does that I didn't originally plan for: scheduled publishing. A post can sit as a draft with a future publish time, and a background job checks every thirty seconds for anything whose time has come, flips it live, and stamps the actual publish timestamp at that moment rather than whenever I happened to hit save. It's a small mechanism, one ticking loop and two SQL statements underneath, nothing that needed a job queue or anything fancier. I mostly built it so I could write a few posts in one sitting on a slow evening and not have all of them go live at once, spacing them out instead without having to remember to come back and hit publish on each one individually.

self-hosting realities, since that part isn't free either

Running this myself instead of paying a platform means every outage is mine to notice and mine to fix, on my own schedule, usually discovered by me trying to check something rather than any kind of alert. Backups matter more here than they would on a managed platform, since there's no vendor quietly keeping a copy of my database somewhere I don't have to think about. I've got both Postgres databases dumped nightly and pushed somewhere off this actual box, because a single-machine setup is also a single point of failure, and finding that out during an actual disk failure would be a genuinely bad way to learn the lesson.

What actually breaks, in practice, is smaller and more boring than I expected going in: a container occasionally needs restarting after a host reboot I forgot to account for, disk space creeps up slower than I'd guessed but does creep up, and the one actual scare so far was a Postgres data directory permission mismatch after moving to new storage that took an evening to sort out and briefly made me very glad the backups actually existed instead of being a thing I'd meant to test and never had.

Restarting a service is one thing when the box itself is sitting a few meters from me. It's a different feeling entirely knowing that if the whole machine died outright, the actual recovery plan is a fresh box, a restored database dump, and however long it takes to get docker compose running again from scratch, probably most of an evening if everything I've written down about the setup is actually accurate and nothing has quietly drifted since I last checked. I haven't tested that full scenario yet, which is exactly the kind of thing I know I should do on a calm weekend rather than find out the hard way during an actual outage.

what's next

Real comment moderation tools, since right now I'm doing that by hand through the database directly, which is fine at the current tiny scale and won't stay fine. Actually writing more, now that the "build the platform" excuse for not writing anything has mostly run out. And probably, eventually, actually going and climbing one of the mountains I keep watching from the ferry instead of just writing about wanting to.

1 comment

Log in to comment.

tore_b June 25, 2026

Building your own is the least efficient and most educational option on the table and I respect it every time. You will learn more shipping this than in a year of tutorials.

Log in

No account?