Skip to content

Engineering notes

How this site is built

fransn.dev is a personal site that I run like a small production platform. The code is private, so this page shows what runs it, why I built it this way and what went wrong.

Live traffic numbers are on the metrics page.

Facts checked against the repository on October 11, 2026.

Architecture

Everything runs on one virtual server at Hetzner with K3s, a small Kubernetes distribution. Cloudflare only answers DNS, so traffic goes straight to the server.

Architecture of fransn.devRequests go from the visitor to ingress-nginx on a single-node K3s server, which routes them to four services. Deploys go from GitHub Actions through GitHub Container Registry and SSH. The numbered list after the diagram describes the same flow in text.Hetzner server · single-node K3slookupHTTPSchatSSH + kubectlVisitorbrowserCloudflare DNSDNS onlyingress-nginxTLS via cert-managerfsn-webNext.js 16 · pagesfsn-apiFastAPI · /apifsn-ingestFastAPI · /ingestfsn-aiFastAPI · /aiPostgreSQL 16posts, commentsShared volumeParquet filesOpenAI APIGitHub ActionsCI and CDGHCRcontainer images
Request path in blue, deploy path and data flows in grey.

The same diagram in words

  1. Your browser asks Cloudflare DNS for fransn.dev. Cloudflare returns the server's address and stays out of the traffic path.

    Sources: [1]

  2. ingress-nginx on the server terminates TLS with a Let's Encrypt certificate that cert-manager renews.

    Sources: [1][3]

  3. It routes by path: pages go to fsn-web (Next.js), /api to fsn-api, /ingest to fsn-ingest and /ai to fsn-ai. The last three are FastAPI services.

    Sources: [3]

  4. fsn-web renders pages on the server and reads blog posts and the profile from fsn-api. Posts, comments and subscribers live in PostgreSQL.

    Sources: [3]

  5. If you accept analytics, your browser sends page views to fsn-ingest, which writes Parquet files to a shared volume. fsn-api reads them with DuckDB for the metrics page.

    Sources: [8]

  6. The chat bubble talks to fsn-ai, which calls the OpenAI API with a persona built from my profile files.

    Sources: [9]

  7. GitHub Actions builds the images, pushes them to GitHub Container Registry and deploys over SSH with kubectl. The server then pulls the new images.

    Sources: [5]

Path to production

Every change takes the same route. A merge to main is a deploy, so the checks run before the merge.

  1. Work starts on a branch, usually from a written spec or plan, and goes up as a pull request.

    Sources: [12]

  2. CI lints and tests the three Python services. For the Next.js app it runs ESLint, the unit tests and a production build, then crawls that build as Googlebot to check the SEO tags. It also builds all four Docker images.

    Sources: [4]

  3. Once CI is green, the pull request is squash-merged, so each change is a single commit on main.

    Sources: [5]

  4. CD rebuilds only the services whose files changed, tags each image with the commit SHA and pushes it to GHCR. The deploy job starts only if every build passed.

    Sources: [5]

  5. Over SSH, it applies the Kubernetes manifests and updates one service at a time. Each rollout starts the new pod before stopping the old one and waits until it reports ready.

    Sources: [5][3]

  6. A smoke test requests the public pages and every service's health endpoint on fransn.dev. If something fails, the job prints pod status and recent cluster events.

    Sources: [6][5]

  7. When page content may have changed, CD sends the sitemap's URLs to IndexNow so Bing and other engines recrawl them.

    Sources: [6][11]

Decisions and trade-offs

Each choice has a cost. These are the ones I would explain first in a design review.

One server instead of a managed cluster

Why
In February 2026 the site moved from Azure AKS to a single Hetzner server running K3s. Per the February 2026 cost notes, it runs for about €4 a month, against about €56 before, and it is still real Kubernetes, with manifests, kustomize overlays and network policies.
Trade-off
No high availability. If the server goes down, the whole site goes with it.

Sources: [2][3]

Cloudflare for DNS only

Why
The site used to sit behind Cloudflare's proxy. Some mobile networks could not reach the IPv6 range Cloudflare gave this domain, and the free plan can't turn IPv6 off, so I switched to a plain DNS record.
Trade-off
No CDN cache and no Cloudflare protection in front of the server.

Sources: [1]

Deploys over SSH, without a GitOps controller

Why
Argo CD or Flux would run inside the same small server. A workflow that runs kubectl over SSH needs nothing extra in the cluster.
Trade-off
Nothing watches for drift between the repository and the cluster, and there is no automatic rollback.

Sources: [2][5]

Plain SQL migrations instead of Alembic

Why
fsn-api runs idempotent SQL statements (CREATE TABLE IF NOT EXISTS and similar) on every start. For a handful of tables that is easy to read and to review.
Trade-off
There are no down migrations and no version history. During a rollout the old and new pods share the database, so every schema change has to work with both.

Sources: [7]

First-party analytics, only with consent

Why
There are no third-party trackers. If you accept, page views go to fsn-ingest as files, and fsn-api queries them with DuckDB. A nightly job deletes data older than 90 days.
Trade-off
Visitors who decline are not counted, so the metrics page undercounts on purpose.

Sources: [8]

A chat agent with a daily spending cap

Why
fsn-ai uses gpt-4o-mini with a persona built from my profile files and adds up what each answer costs. Once the daily cap is reached, it declines politely until the next day.
Trade-off
The spend counter lives in memory, so a restart resets it. That is acceptable for a small cap.

Sources: [9]

A strict Content Security Policy with nonces

Why
Every response gets a fresh nonce, and only scripts that carry it can run. Violation reports go to a small endpoint in the Next.js app.
Trade-off
Pages render per request, so there is no static HTML cache. Styles still allow inline CSS.

Sources: [10]

SEO as code, checked in CI

Why
The sitemap, canonical links, hreflang, Open Graph tags and JSON-LD all come from code. CI starts the built app against a mock API, crawls every sitemap URL as Googlebot and fails if a tag is missing or wrong.
Trade-off
The crawl adds time to every CI run, and it only knows the rules I wrote down.

Sources: [11][4]

Specs and AI coding agents, with review

Why
Features start as a written spec or plan in the repository, using GitHub Spec Kit. A lot of the code was written by AI coding agents, first GitHub Copilot and now Claude Code. Each change still gets a separate review of the diff, CI and the smoke test after deploy.
Trade-off
Review can still miss what no test covers, which is why the CI crawl and the post-deploy smoke test exist.

Sources: [12][4][6]

Things that broke

Short notes on real problems, what caused them and what changed afterwards.

The privacy page returned an error for seven months

What happened
From late February 2026, /privacy failed with a server error in both languages.
Cause
The page passed its link as a rich-text tag, while the translation still expected a plain value. The i18n library threw while rendering.
Fix
The new SEO crawl in CI found it (PR #50). The translation now uses the tag, and a unit test renders that line.
What changed
The smoke test only covered some pages. CI now crawls every page in the sitemap.

Sources: [11]

The sitemap never listed blog posts

What happened
Search engines saw the static pages but none of the posts.
Cause
The sitemap was prerendered during the Docker build, where the API isn't reachable, so the post list came out empty every time.
Fix
It now renders per request, with the post list cached for five minutes (PR #50).
What changed
Anything that depends on live data should not be built into the image.

Sources: [11]

The CSP nonce was empty

What happened
The script that sets the colour theme ran with an empty nonce, so the browser blocked it on every page and dark mode flashed light on load. CSP violation reports never arrived either.
Cause
The middleware wrapped the i18n middleware's response in a second one, which overwrote the list of forwarded request headers, so the nonce never reached the page. Separately, the report endpoint sat under /api, which the ingress sends to fsn-api.
Fix
The nonce is now set on the request before the i18n middleware runs, and reports go to /csp-report (PR #49). A unit test checks that the nonce reaches the page.
What changed
A security header can fail without any visible error. It needs a test, and a look at the live page after deploy.

Sources: [10]

Admin login broke when GitHub changed its OAuth response

What happened
Signing in to the admin panel with GitHub failed with an invalid-response error.
Cause
GitHub started adding an iss parameter to the OAuth callback (RFC 9207). The auth library checks it against the provider's issuer, and the configured issuer didn't match.
Fix
A manual workflow that reads Auth.js errors from the production logs found the cause (PR #52). The fix declares GitHub's real issuer (PR #53).
What changed
Login is outside the smoke test, and identity providers change their responses. Production auth errors are now one workflow run away.

Sources: [13]

Known limits

Things I have chosen not to pay for yet, or not fixed yet.

  • One server and no high availability. Maintenance on that server takes the site down.

    Sources: [2]

  • The database is backed up automatically, but the dumps stay on the same server.

    Sources: [3]

  • CD compares only the last commit to decide what to rebuild. Squash merges keep that safe, but a push with several commits could skip a service.

    Sources: [5]

  • Type checks for the Python services and the dependency audits in CI report problems without failing the build.

    Sources: [4]

Want the details?

The repository is private. I'm happy to walk you through the code, the pipeline or any of these decisions. Get in touch.

Sources

Every claim on this page maps to a file or pull request in the private repository.

  1. docs/architecture/network.md
  2. docs/lessons-learned-k3s-hetzner-cloudflare.md (sections 1, 14 and 16)
  3. infra/k8s/base and infra/k8s/overlays/production
  4. .github/workflows/ci.yaml
  5. .github/workflows/cd.yaml, PR #40
  6. scripts/smoke-prod.sh, scripts/indexnow-submit.sh
  7. services/fsn-api/src/core/migrations.py
  8. services/fsn-ingest, services/fsn-api/src/services/metrics_summary.py, services/fsn-web/src/lib/telemetry.ts, infra/k8s/base/cleanup, PR #43
  9. services/fsn-ai/src/core/config.py, services/fsn-ai/src/services/spend_tracker.py
  10. services/fsn-web/src/lib/csp.ts, services/fsn-web/middleware.ts, PR #34, PR #49
  11. scripts/seo-check.mjs, services/fsn-web/src/app/sitemap.ts, PR #50
  12. specs/ (21 spec folders), .specify/, commit co-author trailers
  13. PR #52 (auth log workflow), PR #53 (RFC 9207 issuer fix)