The infrastructure behind this portfolio. A home server that hosts git repos, API backends, and live project demos. Every API call and demo on this site routes through hardware in my house.
The philosophy is no managed services beyond Cloudflare's CDN. Git hosting, CI/CD, push notifications, monitoring, all self-hosted. Not because cloud services are bad, but because running your own infrastructure is the best way to understand it.
Two access tiers: public internet and private mesh.
Public: a Cloudflare Tunnel from the server to Cloudflare's edge. No exposed ports, no static IP, no port forwarding. Requests to kschappell.com hit Cloudflare Pages for static assets; API and demo traffic routes through the tunnel to Nginx on the server, which reverse-proxies to the appropriate Docker container.
Private: a Tailscale mesh VPN for services that shouldn't be public (Home Assistant, Jellyfin, Immich, Vaultwarden). Accessible only from my own devices, regardless of what network I'm on. The combination covers both access patterns without opening a single port on the router.
Each service runs in its own Docker container with two networks: an internal bridge for service-to-service communication and a public-facing network for external access. Container isolation means I can blow away and rebuild any service without affecting the rest. The whole stack rebuilds from a single docker-compose file.
The backend container (FastAPI on Python 3.12) handles the contact form, activity feed, health monitoring, and CV downloads. Ntfy runs alongside it for self-hosted push notifications, no third-party notification service. The backend talks to Ntfy over the internal Docker network; external traffic only reaches services through Nginx.
Gitea Actions with a self-hosted runner on the same server. Path-filtered workflows: frontend changes (anything outside backend/ and deploy/) trigger a Node build and push to Cloudflare Pages via Wrangler. Backend changes (backend/ or deploy/) SSH into the server and rebuild the Docker container.
Frontend and backend deploy independently. A CSS tweak doesn't rebuild the API, and a new endpoint doesn't redeploy the static site. The runner and the deploy target being the same machine simplifies everything: no remote SSH from a cloud CI service, no credentials to manage beyond a Cloudflare API token for the frontend pipeline.
A status API polls each service's health endpoint with a 5-second timeout, caches results for a minute. Services are defined via environment variable, so adding a new one means adding a URL, not writing code. The portfolio's status dashboard reads from this API. If something goes down, I see it on the site before anyone reports it.
Activity tracking pulls recent commits from the Gitea API across all repos, with a 5-minute cache and stale fallback (up to an hour) if the fetch fails.
Public Services
Private Services - Tailscale VPN
CI/CD Pipeline