Every social network's architecture is downstream of its business model. Ads need engagement optimization; engagement needs tracking; tracking needs your identity graph. Remove the ads and the architecture gets radically simpler — simple enough that one small team can run a real network as a community service.

circle is that experiment. Here's the whole machine.

Architecture: browser → edge → Next.js + Fastify → PostgreSQL + MinIO, Authentik identity lane below

Identity: deliberately no email

The most radical choice is the missing field. Signing up asks for a handle, a password, and an authenticator app. That's the entire identity.

We run Authentik as the identity provider. Its killer feature is that login and enrollment are flows you configure, not code you write: our enrollment flow chains prompt → user creation → mandatory TOTP setup → recovery-code issuance, all declared in the IdP. The app itself only ever speaks standard OIDC with PKCE and stores a __Host-, HttpOnly session cookie.

No email means:

  • nothing to leak in a breach that identifies you elsewhere,
  • no password-reset phishing surface,
  • and an honest trade: your recovery codes are your only way back in. We say that out loud during signup instead of hiding it in a help doc.

circle is invite-only right now. Invites are single-use, expiring tokens validated by the IdP before the enrollment form even renders:

Invitation flow: member generates single-use link → you pick a handle → password + TOTP + recovery codes → in

Feeds: an activity stream in plain SQL

The home feed isn't a list of posts — it's a union of activities: posts by people you follow, and reposts by people you follow surfacing at repost time with attribution. One CTE handles it:

WITH activity AS (
  SELECT p.id, NULL::bigint AS reposter, p.created_at AS at
    FROM posts p WHERE p.author_id IN (…following…)
  UNION ALL
  SELECT r.post_id, r.profile_id, r.created_at
    FROM reposts r WHERE r.profile_id IN (…following…)
)
SELECT DISTINCT ON (id) … ORDER BY at DESC

Cursor pagination rides the activity timestamp. Like/reply/repost counters are denormalized columns maintained by triggers — feeds never count rows at read time. At community scale, PostgreSQL does all of this without a cache tier, a queue, or a fan-out service. The lesson generalizes: most "you'll need Kafka for that" advice assumes you're building for a billion users you don't have.

The rest of the machine

  • API — one Bun + Fastify service: posts, threaded replies, follows, likes, bookmarks, notifications, DMs (per-side read high-water marks, so unread counts are exact and marking read is free), and per-account sliding-window rate limits on every write.
  • Link previews — an SSRF-hardened unfurler: DNS-resolve first, refuse private address space, follow at most one re-validated redirect, read 256KB max. If you fetch URLs users give you, this checklist is not optional.
  • Media — MinIO (S3-compatible) with anonymous read on exactly two prefixes and credentialed writes only.
  • Web — Next.js with server-rendered post pages (real OG cards when links are shared) wrapped around a client feed.
  • Moderation — reports, blocks that sever the social graph both ways, bans enforced at one chokepoint in the API.

Everything runs as containers on a self-hosted Kubernetes cluster behind Traefik with auto-issued TLS, security headers at the edge, and nightly database backups.

What we'd tell you to steal

  1. OIDC + a real IdP instead of hand-rolled auth — flows-as-config is a superpower.
  2. Triggers + denormalized counters before any cache.
  3. The activity-stream union for feeds with reposts.
  4. The SSRF checklist for unfurlers.
  5. And the meta-lesson: no ads didn't just simplify the ethics — it deleted about half the architecture.

Reading circle is public: circle.xwira.tech. Writing needs an account, and accounts need an invite from a member.