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:
- Reads
posts/index.jsonfor the post list (title, slug, date, language) - Serves
GET /blog/{slug}which returns the page with the post slug in a template variable - 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:
- Write the Markdown file in
posts/en/andposts/ru/ - Add an entry to
posts/index.json - 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 statusswanctl --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
addPrefixmiddleware — 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 imageover SSH — no FluxCD needed for a single personal VPS - Full stack costs ~€130/year: VPS + domain, everything else is free open source