How this site is built

Published: 2026-06-01

This personal site is a tiny FastAPI app running inside a single-node k0s Kubernetes cluster on a VPS in Prague. Here's a full walkthrough of how it's put together.

Stack

  • FastAPI + Jinja2 templates, served by uvicorn behind Traefik
  • Docker image pushed to a self-hosted registry
  • k0s — lightweight Kubernetes distribution, single node
  • Traefik as ingress with automatic Let's Encrypt TLS via cert-manager
  • FluxCD is used in day-job clusters; here deploys are shell-scripted (06-deploy-site.sh)
  • GitHub for the source repo; push triggers a CI pipeline that builds and pushes the image

Subdomains

Domain What
antonnovikov.com main site
cv.antonnovikov.com CV & resume viewer
proxy.antonnovikov.com HTTP / SOCKS5 / Shadowsocks proxy
vpn.antonnovikov.com IKEv2 & WireGuard VPN
blog.antonnovikov.com this blog

Each subdomain gets an addPrefix Traefik middleware that rewrites the path prefix so the same FastAPI app handles everything. There's no separate process per subdomain.

yaml# middleware for blog subdomain
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: blog-prefix
  namespace: web
spec:
  addPrefix:
    prefix: /blog

The IngressRoute for blog.antonnovikov.com attaches this middleware, so a request to https://blog.antonnovikov.com/2026-05-01-post becomes /blog/2026-05-01-post internally.

Application structure

app/
  main.py          # FastAPI app, CORS, static files
  config.py        # Settings from env vars
  auth.py          # Simple token auth for status endpoints
  templates.py     # Jinja2 env setup
  routers/
    main.py        # index, health
    blog.py        # post listing + rendering
    cv.py          # CV/resume download
    proxy.py       # proxy status + stats
    vpn.py         # VPN status + QR codes

Each subdomain corresponds to a router. The app is a monolith by choice — there's no benefit to microservices here.

Blog

Posts are plain Markdown files in posts/en/ and posts/ru/. The blog router:

  1. Reads posts/index.json for the post list (title, slug, date, language)
  2. Serves GET /blog/{slug} which returns the page with the post slug in a template variable
  3. The template fetches the raw Markdown via /blog/raw/{slug} and renders it client-side with marked.js

No database, no CMS, no build step. Adding a new post means:

  1. Write the Markdown file in posts/en/ and posts/ru/
  2. Add an entry to posts/index.json
  3. Commit and push
json{
  "slug": "2026-05-30-tor-rotating-proxy",
  "title": "Rotating Tor HTTP proxy in Kubernetes",
  "date": "2026-05-30",
  "lang": "en"
}

VPN & Proxy status

A systemd timer runs a Python collector every 30 seconds on the host. It queries:

  • wg show wg0 dump — WireGuard peer status
  • swanctl --list-sas — IKEv2 sessions
  • tinyproxy log counters
  • SOCKS5 and Shadowsocks pod logs

All data is merged into /var/lib/vpn-status/status.json. The FastAPI app reads that file on every status request — no database, no push, just a file read.

CI/CD pipeline

yamlstages:
  - build
  - deploy

build:
  image: docker:24
  services: [docker:dind]
  script:
    - docker build -t registry.example.com/antonnovikov-com:$CI_COMMIT_SHORT_SHA .
    - docker push registry.example.com/antonnovikov-com:$CI_COMMIT_SHORT_SHA
    - docker tag ... :latest && docker push ... :latest

deploy:
  script:
    - ssh root@vps 'bash -s' < k0s-setup/06-deploy-site.sh

06-deploy-site.sh runs kubectl set image deployment/web web=registry.../antonnovikov-com:$SHA and waits for rollout.

Kubernetes manifests

yaml# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: web
spec:
  replicas: 1
  strategy:
    type: RollingUpdate
  template:
    spec:
      containers:
      - name: web
        image: registry.example.com/antonnovikov-com:latest
        ports:
        - containerPort: 8000
        resources:
          requests:
            memory: "64Mi"
            cpu: "50m"
          limits:
            memory: "256Mi"
            cpu: "200m"
        env:
        - name: AUTH_TOKEN
          valueFrom:
            secretKeyRef:
              name: web-secret
              key: auth-token

The secret is deployed manually once: kubectl create secret generic web-secret --from-literal=auth-token=....

TLS

Traefik handles TLS via cert-manager + Let's Encrypt. The Certificate resource is created once per domain. Cert renewal is automatic.

yamlapiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: antonnovikov-com
  namespace: web
spec:
  secretName: antonnovikov-com-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - antonnovikov.com
    - "*.antonnovikov.com"

The wildcard cert covers all subdomains with a single certificate.

Why not use a static site generator?

Because I already had FastAPI for the proxy/VPN status endpoints, and adding a blog is literally 30 lines of Python plus a template. A static generator would require a build step, a CDN or file server, and separate hosting for the dynamic endpoints.

The downside is cold start: the first request after a pod restart takes ~2 seconds while uvicorn loads. With a single replica on a personal VPS, this is acceptable.

Cost

  • VPS: ~€10/month (2 vCPU, 4GB RAM, 50GB SSD)
  • Domain: ~€12/year
  • Everything else: free (k0s, cert-manager, Traefik, Let's Encrypt, self-hosted registry)

Total: ~€130/year for a site that serves a few hundred visitors/month. Probably not cost-efficient by cloud metrics, but it runs a dozen other services on the same VPS.


What can go wrong

Cold start after pod restart takes too long. uvicorn starts in ~2 seconds, but if the liveness probe fires before that, Kubernetes kills the pod and loops. Set initialDelaySeconds: 5 on the liveness probe.

cert-manager can't solve the DNS-01 challenge. Wildcard certificates require DNS-01. If your DNS provider isn't supported or the API token has expired, cert-manager will silently retry and TLS will eventually expire. Check with kubectl describe certificate -n web and kubectl logs -n cert-manager.

Registry unreachable during deploy. 06-deploy-site.sh calls kubectl set image with the new SHA. If the VPS can't reach the registry, the pod will be stuck in ImagePullBackOff. The previous deployment keeps serving traffic until the timeout.

Secret not found on fresh VPS. The web-secret is created manually once with kubectl create secret. If the cluster is rebuilt without restoring it, the pod crashes on startup with CreateContainerConfigError.


Summary

  • FastAPI monolith serves multiple subdomains via Traefik addPrefix middleware — no per-subdomain process
  • Blog is plain Markdown files + index.json, no database, no build step
  • VPN/proxy status comes from a file written by a systemd timer every 30 seconds
  • CI builds a Docker image and deploys via kubectl set image over SSH — no FluxCD needed for a single personal VPS
  • Full stack costs ~€130/year: VPS + domain, everything else is free open source