Building Floeberg
Build log for floeberg.com. Why I self-hosted gotrue, how Stripe → DB → Discord stays idempotent, and one chat table projected to two surfaces.
I shipped a new site this month: floeberg.com. New brand, new reader, new work. This is the build log. (Two sites, one operator covers why the brand split was necessary; this post is the technical companion.)
The thesis of Floeberg is the depth layer below the AI waterline, the security and platform and architecture work that decides whether an AI product survives eighteen months. So the site itself should be a depth-layer build, not duct tape. Here is what that looked like.
The stack, in one breath
- Next.js 16, App Router,
output: standalone - Self-hosted Supabase Auth (gotrue v2) on
auth.floeberg.com - One Postgres 17 instance (the
supabase/postgresimage, pgvector + pg_cron + pgsodium baked in), shared by the app schema and the gotrue schema - Stripe Checkout for the three scoped audit products
- A separate Discord bot that bridges to the site's "wave" channel
- Traefik + Let's Encrypt + Watchtower on a single Hetzner box (the same box that hosts danoh.com)
- Resend for both gotrue magic-link delivery and transactional brand emails
Three of those choices are worth unpacking.
Self-hosted gotrue, not hosted Supabase
The easy path was hosted Supabase. Free tier covers a project this size, magic-link auth out of the box, no infrastructure to think about. I went the harder path and ran gotrue myself behind auth.floeberg.com.
Two reasons. One: a depth-layer brand has to demonstrate it can run depth-layer infrastructure. If I outsource the auth stack of my own consulting site, the pitch about owning your foundations gets thinner. Two: a single Postgres instance can serve both app data and gotrue data on different schemas. One database, one backup story, one place to look during an incident. Hosted Supabase splits those.
What it cost: about two days of Traefik routing pain. Gotrue expects to live at the root of its host, but @supabase/ssr on the client side expects URLs at /auth/v1/*. The Traefik StripPrefix rule that bridges those should take fifteen minutes and didn't. Once it's right, it's three lines of compose labels.
The runtime cost is single-digit MB of RAM. The deploy story is "same compose stack as the app." No additional surface.
The audit → DB → Discord pipeline
A buyer hits Stripe Checkout for one of the audit products. Stripe sends a checkout.session.completed webhook. The handler writes the user's appRole to waterline in Postgres. A follow-on call hits the Discord bot's HTTP endpoint, which assigns the Waterline role on the server.
Three systems, one flow, one source of truth. The DB is the truth. The Discord role is a projection.
If a role drifts (someone leaves the server and rejoins six months later) a reconciliation job reads the DB and re-applies. The webhook handler never trusts Discord state. It writes to the DB and lets the projection catch up.
Why route through the DB instead of going Stripe → Discord directly: Stripe webhooks are at-least-once. A retried webhook in the inline-assign world would re-assign and the role manager would log a state change for a no-op. Routing through the DB makes the operation idempotent at the right layer. The DB upsert is the keystone; everything downstream reconciles from it.
The wave: one channel, two surfaces
Floeberg has a public channel at floeberg.com/wave. The same channel exists in the Floeberg Discord server. Messages from either side land in the same Postgres table ordered by createdAt. The web client subscribes to a Postgres LISTEN/NOTIFY feed for live updates; the Discord bot listens for new messages and mirrors them in.
The design constraint: chat never lives in two places. There is no "Discord version" and "web version." Both surfaces write to the same WaveMessage table. The Discord bot's job is to project the table into Discord and project Discord messages back. The web's job is to render the table.
That is the cold-blooded answer to a community feature without running a chat product.
The deploy
Same pattern as danoh.com. Push to main → GitHub Actions builds dddd4444/floeberg-landing:latest → Watchtower polls Docker Hub every five minutes → the stack restarts. One Hetzner box, two sites, two databases, one Traefik routing by Host header. The compose stack lives at /opt/stacks/floeberg-landing/, the .env is mode 0600, the cert auto-renews.
I have not SSH'd to the box in a month.
That last line is the depth thesis in one sentence. The work below the waterline is supposed to be invisible.
© 2026 Daniel Oh · danoh.com/blog/building-floeberg