---
title: "Building Tirraflow Part 1: A Full-Stack Blog Platform from Scratch"
url: "https://tirraflow.com/posts/how-i-built-tirraflow-a-full-stack-blog-platform-from-scratch"
author: "taoufiq"
published: "2026-03-10T21:59:10Z"
updated: "2026-09-25T21:01:46Z"
category: "Full-Stack Engineering"
difficulty: "intermediate"
series: "Building Tirraflow"
series_part: 1
series_url: "https://tirraflow.com/series/building-tirraflow"
tags: ["Go", "Docker", "Keycloak"]
---

# Building Tirraflow Part 1: A Full-Stack Blog Platform from Scratch

I know what you're thinking — why build a blog platform when WordPress, Ghost, and Medium already exist?

Honestly? I wanted to learn. I wanted to make decisions, break things, fix them, and understand every layer of a real production app. Not from a tutorial. From actually shipping something.

So I built **Tirraflow** — the platform you're reading this on right now. And I'm going to tell you how.

---

## The Stack

Here's what I landed on after some back and forth:

- **Frontend:** React 19 + Vite + Tailwind CSS
- **Backend:** Go with Gin and PostgreSQL
- **Auth:** Keycloak (OAuth2 with PKCE)
- **Deployment:** Docker Compose on a Hetzner VPS, behind Cloudflare

Here's an overview of how it all fits together:

![Architecture overview: React SPA, Go API, PostgreSQL and Keycloak behind Nginx and Cloudflare](https://tirraflow.com/uploads/05b244b4-65c7-4674-bccf-1aa5080c1851.png)

---

## The Frontend

I went with React and Vite because honestly, the developer experience is hard to beat. Hot reload in milliseconds. Production builds in under 3 seconds. And React 19's ecosystem just has everything you need.

The interesting parts:

**TanStack Query changed everything.** Before I added it, I was manually managing loading states, caching, and refetching everywhere. Now, hovering over a post card silently prefetches the article data. By the time you click, the page loads instantly. Likes and bookmarks update optimistically — you see the result before the server responds.

**Dark mode was more work than I expected.** Every single Tailwind class needs a `dark:` counterpart. It sounds simple until you're 40 components deep and realize you missed the border color on a dropdown. But the result is worth it — the theme respects your system preference and remembers your choice.

**The editor was the hardest part.** I wanted both a rich text editor (for people who prefer visual editing) and a Markdown mode (for developers). TipTap handles the rich text side — tables, code blocks with syntax highlighting, resizable images.

---

## The Backend

I picked Go for the backend, and I'm glad I did. One compiled binary, no runtime dependencies, and it starts in milliseconds. The entire API — posts, comments, auth, file uploads, search, RSS — runs without heavy frameworks or abstractions.

Every handler follows the same boring pattern: validate input, call the database, return a response. No magic. No hidden layers. Just straightforward code that does what it says.

**One decision I'm proud of: skipping Elasticsearch (for now).** I almost added it for search. Then I tried PostgreSQL's built-in full-text search `tsvector` and `tsquery` — and it does everything a blog needs. Prefix matching, result ranking, context snippets. One less service to worry about.

It's simpler than you think: one `tsvector` column, a GIN index on it, and a trigger that keeps it current whenever a post is saved.

---

## Authentication

I did not want to roll my own auth. That's how you end up on the news !

Keycloak handles everything: login, registration, password reset, OAuth2 tokens, role management. I just had to integrate it. The tricky part was making it **not** annoying.

Most Keycloak tutorials show you a "Loading authentication..." screen that blocks the entire app on every page load. That's terrible for a blog — most visitors are anonymous readers who don't care about auth. So I made it non-blocking: the app renders immediately, and Keycloak silently checks your session in the background using a hidden iframe. First-time visitors see the blog instantly. Returning users get logged in without noticing.

Getting that right took some trial and error. I wrote a dedicated article about it: [Keycloak Silent SSO in React](https://tirraflow.com/posts/keycloak-silent-sso-in-react-no-loading-screens-no-redirects).

---

## Deployment

The whole platform runs on a single Hetzner VPS. Here's what happens when you open an article:

![Request flow for opening an article: Cloudflare, Nginx, the frontend and API containers, PostgreSQL and Keycloak on two Docker networks](https://tirraflow.com/uploads/9200693a-5841-42d6-ba07-333fb7126aed.png)

Five containers: Nginx, the frontend, the API, PostgreSQL, and Keycloak (six today: a backup service joined them, see the update below). Two networks — one public-facing, one internal.

Both the frontend and backend use multi-stage Docker builds. The frontend compiles with Node, then gets served by a tiny Nginx image. The Go API compiles to a single binary and runs on a minimal Debian image. Small images, fast deploys.

GitHub Actions runs lint, tests, and builds on every push. The backend pipeline even spins up a real PostgreSQL container for integration tests — no mocks.

---

## What I Learned

A few things that surprised me:

**Validation has to live in two places.** The frontend validates for a smooth user experience — character counters, max lengths, instant feedback. The backend validates for security — it's the real gatekeeper. Both enforce the same limits. I keep them synced with a shared table in the docs. It's tedious, but necessary.

**Small UX details matter more than features.** The reading progress bar at the top of articles. Keyboard shortcuts (`/` to search). Auto-saving drafts every 30 seconds. Skeleton loading screens instead of spinners. None of these are "features" but they're what make the app feel polished.

**Start simple. Seriously.** I could've spent months designing a perfect architecture. Instead, I shipped with Docker Compose on a single VPS. It works. When I need to scale, I'll scale. But right now, simplicity wins.

---

## Update: Six Months Later

The shape above still holds; what changed is around the edges. Keycloak moved to the same origin under `/auth`, because browsers started partitioning cookies inside cross-site iframes (Part 2 has the story). A sixth container dumps both databases and the uploaded images every night. A merge to `main` deploys itself, waits for health checks and rolls back on failure (Part 4). And every article is also served as plain Markdown, with an `llms.txt` index, so AI agents read the text instead of a JavaScript shell.

---

## What's Next

[Part 2: Keycloak Silent SSO in React, No Loading Screens](https://tirraflow.com/posts/keycloak-silent-sso-in-react-no-loading-screens-no-redirects) — non-blocking auth for a SPA, and why Keycloak ended up same-origin.

---

## The Series

_Building Tirraflow_ is a 5-part series on how the platform you are reading was built and run; every part is linked below.

| Part | Topic |
| --- | --- |
| **Part 1** (this post) | The stack, the architecture, and what I learned shipping it |
| [Part 2](https://tirraflow.com/posts/keycloak-silent-sso-in-react-no-loading-screens-no-redirects) | Non-blocking auth for a SPA, and why Keycloak ended up same-origin |
| [Part 3](https://tirraflow.com/posts/e2e-testing-a-react-spa-with-playwright-no-backend-required) | Testing the whole frontend with no backend at all |
| [Part 4](https://tirraflow.com/posts/cicd-auto-deploy-with-github-actions-from-push-to-production) | From push to production, and how the pipeline learned to roll back |
| [Part 5](https://tirraflow.com/posts/hardening-a-self-hosted-vps-after-a-cryptominer-got-in) | How it got in, and the six layers that keep it out |
