Kubernetes (k3s)
avocado runs a single-node k3s cluster (modules/k3s.nix). Workloads live under k8s/.
The cluster
- Role:
serverwithclusterInit = true→ embedded etcd, so more servers/agents can join later for HA (today it’s one node). - Token: from sops (
k3s/token) — any long random string; joining nodes reuse it. - API cert: issued for the tailnet name via
--tls-san=avocado(andavocado.local), so remotekubectl/Lens over Tailscale trust it. - Kubeconfig mode:
0640. - Bundled add-ons left ON: Traefik (ingress), local-path (default StorageClass), and ServiceLB.
- On-box tooling:
kubectl,helm,k9s.
Getting a kubeconfig
just kubeconfig # writes ~/.kube/avocado with the server rewritten to `avocado`
export KUBECONFIG=~/.kube/avocado
kubectl get nodes
just kubeconfig copies /etc/rancher/k3s/k3s.yaml off the box and rewrites 127.0.0.1 → avocado so it works from the Mac (or Lens).
Storage
Everything uses the built-in local-path provisioner, which carves PersistentVolumes out of the host filesystem under /var — i.e. on the ZFS rpool. Consequences worth repeating:
- PVCs are ReadWriteOnce and node-local (fine — there’s one node).
- All PVC data sits on the no-redundancy ZFS stripe. Capacity and disk health are alerted on by the monitoring stack.
Ingress model
Traefik is the single ingress controller. Both the Cloudflare Tunnel and Tailnet access funnel to Traefik on :80, which routes by Host header. Each workload just declares an Ingress with its public host.
Workloads
Sample smoke test — k8s/sample.yaml
A 2-replica nginxdemos/hello Deployment + Service + Ingress (hello.avocado.local, hello.rithviknishad.dev) to confirm the cluster and ingress path work end-to-end.
kubectl apply -f k8s/sample.yaml
curl -H 'Host: hello.avocado.local' http://avocado/
kubectl delete -f k8s/sample.yaml
Immich (self-hosted photos) — k8s/immich/
Deployed with kustomize (kubectl apply -k k8s/immich). Reachable at https://photos.rithviknishad.dev once the tunnel route and ingress are live.
flowchart TB
ing[Ingress: photos.rithviknishad.dev]
ing --> server[immich-server :2283]
server --> pg[(postgres :5432)]
server --> redis[(redis :6379)]
server --> ml[immich-machine-learning :3003]
server --> lib[PVC immich-library 200Gi]
pg --> db[PVC immich-db 20Gi]
| Component | Image | Storage |
|---|---|---|
immich-server | ghcr.io/immich-app/immich-server:release | immich-library PVC (200 Gi) at /usr/src/app/upload |
postgres | ghcr.io/immich-app/postgres:14-vectorchord… | immich-db PVC (20 Gi) |
redis | redis:7 | — |
immich-machine-learning | ghcr.io/immich-app/immich-machine-learning:release | emptyDir cache |
Secrets: the DB credentials come from a Secret named immich-secret (consumed via envFrom). It is not committed — copy the template and fill a real password:
cp k8s/immich/secret.example.yaml k8s/immich/secret.yaml # gitignored
# edit DB_PASSWORD / POSTGRES_PASSWORD, then add `- secret.yaml` to kustomization.yaml
kubectl apply -k k8s/immich
kubectl -n immich get pods -w
The example secret wires postgres and immich-server together (DB_HOSTNAME=postgres, REDIS_HOSTNAME=redis, matching DB user/name).
ESPHome (ESP32 firmware dashboard) — k8s/esphome/
Runs with hostNetwork: true so mDNS discovery and OTA updates reach the LAN — the exception to the usual ClusterIP pattern. Deployed via just esphome-deploy; documented on its own ESPHome page.
Formance Ledger (standalone) — k8s/formance/
Programmable double-entry ledger + Console UI, deployed with kustomize plus a sops-encrypted Secret (just formance-deploy). Only the Console is exposed, at https://ledger.rithviknishad.dev (behind Cloudflare Access) — the Ledger API and Caddy gateway stay in-cluster. Documented on its own Formance Ledger page.
Bingo (multiplayer game) — k8s/bingo/
Classic 1–25 multiplayer bingo (sonzsara/bingo-app, boardgame.io). Deployed with kustomize (just bingo-deploy); public at https://bingo.rithviknishad.dev.
Unlike the other workloads, there is no upstream or registry image — the image is built by Nix in this repo and preloaded into k3s, so the whole thing stays declarative and pinned:
flowchart TB
input[flake input: bingo-app pinned] --> pkg[pkgs/bingo<br/>buildNpmPackage + dockerTools]
pkg --> img[OCI image bingo-app:latest]
img -->|services.k3s.images<br/>modules/bingo.nix| ctr[(containerd)]
ctr --> pod[bingo pod :8000]
ing[Ingress: bingo.rithviknishad.dev] --> svc[Service bingo :8000] --> pod
- Single origin.
server.cjs(Koa) serves the built SPA and the boardgame.io multiplayer API + websocket on one port (8000). The publicVITE_SERVER_URLis baked at build time so the browser’s socket connects same-origin over 443 — the tunnel only forwards:443 → localhost:80, never:8000, so the app’s default<host>:8000fallback would fail. Websockets ride the tunnel + Traefik unmodified. replicas: 1is deliberate — matches live in in-memory boardgame.io storage, so all players in a match must share one process. Scaling out needs a shared storage adapter.- Public by intent, Access-gated today. It’s a party game meant to be public, but
bingo.rithviknishad.devcurrently sits behind Cloudflare Access (a*.rithviknishad.devpolicy) and answers unauthenticated requests with a login302. Exclude the host from that policy to make it truly public. For this reason its uptime probe hits the in-cluster Service (bingo.bingo.svc:8000), not the public URL — same as esphome/formance — so a login redirect can’t mask a dead backend.
| Component | Image | Notes |
|---|---|---|
bingo | bingo-app:latest (Nix-built, k3s-preloaded) | SPA + boardgame.io server on :8000 |
Deploy / update:
just bingo-deploy # apply namespace/deployment/service/ingress
cloudflared tunnel route dns avocado bingo.rithviknishad.dev # one-time
# Bump the app to a newer upstream commit:
just update bingo-app # then recompute npmDepsHash in pkgs/bingo
just deploy # rebuilds + re-imports the image (restarts k3s)
kubectl -n bingo rollout restart deploy/bingo
The image reaches the box through the normal just deploy (k3s preloads it via services.k3s.images) — just bingo-deploy only applies the manifests.
CARE HMIS + TeleICU — k8s/care/ + k8s/care-teleicu/
Open Healthcare Network CARE (Django API + React SPA + MinIO object storage) plus the 10bedicu TeleICU layer (gateway middleware, RTSPtoWeb stream server, devices micro-frontend, mock ONVIF camera + vitals devices). Deployed with kustomize + sops secrets (just care-deploy, just care-teleicu-deploy); public at https://care.rithviknishad.dev (+ 4 sibling hosts). Four images are built on the box with docker (just care-images, just care-teleicu-images) and imported into k3s’s containerd — upstream either publishes no image or bakes config/plugins in at build time. Nightly pg_dump CronJobs back up both databases. Documented on its own CARE page.
ONVIF Console — k8s/onvif-console/
10bedicu’s onvif-console, a browser tool for probing/PTZ-testing ONVIF cameras (Next.js UI + FastAPI sidecar on one port). Deployed with kustomize (just onvif-console-deploy); public at https://onvif-console.rithviknishad.dev. The image is built on the box with docker (just onvif-console-images) and imported into k3s’s containerd — upstream publishes no image. Because the console has no auth and relays the camera credentials you type in, its public host must sit behind Cloudflare Access (create the Access app before the DNS route), so its uptime probe hits the in-cluster Service, not the login-gated edge — same as esphome/formance/bingo. WebRTC live video (go2rtc) is omitted on purpose (the tunnel can’t carry its UDP media); the console falls back to ONVIF snapshot polling, which is all the conformance/PTZ testing needs. Run history is persisted server-side to a SQLite DB on the onvif-console-data PVC (the backend keeps it per camera). Documented on its own ONVIF Console page.
Kite (Kubernetes dashboard) — k8s/kite/
Kite, a modern Kubernetes dashboard (Go server + SPA, live logs, web terminal, kubectl console). Deployed with kustomize plus a sops-encrypted Secret (just kite-deploy); public at https://kite.rithviknishad.dev. Kite is a full cluster-admin console — its ServiceAccount uses a ["*"] ClusterRole — so it gates itself with GitHub OAuth (only the mapped GitHub user gets in), the one public workload that carries its own login instead of a Cloudflare Access gate. OAuth and RBAC are configured declaratively via a mounted config.yaml (read-only in the UI); the GitHub client secret and a break-glass password inject from the sops Secret. Its uptime probe hits the public /healthz (no auth on that path), so it covers the full edge path + cert. Documented on its own Kite page.
Open Terminology Server — k8s/ots/
Open Terminology Server, OHC’s read-heavy FHIR-ish terminology API (Starlette + Postgres/pgvector, with a Celery worker for embedding jobs — the broker/result backend is Postgres itself, no Redis). One upstream image runs as ots-api + ots-worker. Deployed with kustomize plus a sops-encrypted Secret (just ots-deploy); public at https://ots.rithviknishad.dev and on the tailnet at ots.avocado.local, and reachable in-cluster (e.g. by CARE) at http://ots-api.ots:8000. Unlike the auth-less tools, it carries its own API key — every path is gated by the x-api-key header except /health and the Swagger assets — so, like Kite, its public host does not need a Cloudflare Access gate. The image is built on the box with docker (just ots-images) and imported into k3s’s containerd (upstream publishes no image); an initContainer runs alembic upgrade head on start. Vector search uses CPU-only FastEmbed (bge-small-en-v1.5), cached on a shared data PVC — the box has no GPU. The server boots empty; terminologies (SNOMED CT, LOINC, ICD) are imported with its CLI from release files staged on the data PVC. Documented on its own Terminology Server page.
Attic (Nix binary cache) — k8s/attic/
Attic, a self-hostable Nix binary cache with global dedup + zstd compression. Unlike the OHC services, it uses the upstream image (ghcr.io/zhaofengli/attic) directly — no build-on-box step. A single atticd (API + garbage collector) is the whole cache: SQLite plus a local NAR/chunk store, both on one 50 Gi PVC (no Postgres/MinIO). Deployed with kustomize plus a sops-encrypted Secret (just attic-deploy). Every operation is gated by JWT tokens minted with atticadm (just attic-token). It is not exposed publicly — there is no Cloudflare Tunnel entry; reach it only over Tailscale/LAN at attic.avocado.local (or in-cluster at http://atticd.attic.svc:8080), which also sidesteps the edge’s ~100 MB request-body cap on pushes. Its uptime probe hits the token-less in-cluster root route. Documented on its own Attic page.
Zerodha Kite MCP server — k8s/zerodha-kite/
zerodha/kite-mcp-server, a Go MCP server exposing the Kite Connect trading API to AI clients. Built by Nix (pkgs/zerodha-kite, buildGoModule
dockerTools) from the pinnedkite-mcp-serverflake input and preloaded into k3s viaservices.k3s.images(modules/zerodha-kite.nix) duringjust deploy— same no-registry pattern as Bingo. Runs inhybridmode (serves both/mcpand/sse). Exposed over the tailnet only athttps://avocado.orthrus-bass.ts.net:8443: the pod speaks plain HTTP on a fixed NodePort (30080), and a Tailscaleservefront door (modules/zerodha-kite.nix) terminates TLS with the box’s Let’s Encrypt cert and proxies to it — HTTPS is required because the Kite Connect Redirect URL must be https, and the browser OAuth callback needs one realhost:port. Port 8443 (not 443) because klipper already owns host:80/:443for Traefik. The MagicDNS name resolves only inside the tailnet and the NodePort is firewalled off the WAN/LAN. It carries the full trading tool set (real orders/GTTs), so the tailnet-only exposure is the security boundary.KITE_API_KEY/SECRETcome from a sops Secret (just zerodha-kite-deploy). Namedzerodha-kiteto avoid clashing with the Kite dashboard above. Documented on its own Zerodha Kite page.
Settle Up MCP server — k8s/settle-up-mcp/
rithviknishad/settle-up-mcp, a FastMCP (Python) MCP server exposing Settle Up shared expenses to AI clients — 26 tools spanning groups, members, transactions, balances, recurring templates and the change log, including destructive ones (delete group/transaction/recurring, each gated by a required confirm flag). Unlike the Kite MCP server above it is not Nix-built: it is a first-party repo whose CI publishes a multi-arch image to GHCR, so the pod pulls ghcr.io/rithviknishad/settle-up-mcp:latest with imagePullPolicy: Always and just settle-up-mcp-deploy is the entire upgrade path — no flake input, no just deploy. Serves streamable HTTP only (no SSE) on /mcp, gated by a single static bearer token; /health is the one token-less route, which is what lets the probes and Gatus check it without a token in git. Exposed over the tailnet only at https://avocado.orthrus-bass.ts.net:10000 via the same NodePort (30800) + Tailscale serve pattern as Kite — port 10000 because it is the last of the three HTTPS ports serve allows (klipper owns :443, Kite took tailnet-only is the security boundary: it is pointed at the live Settle Up backend, can delete real groups and transactions, and holds the account password. Documented on its own Settle Up page.
The monitoring workload
The largest thing on the cluster is the observability stack under k8s/monitoring/ (VictoriaMetrics + Grafana + logs + uptime). It has its own deploy flow (helmfile + kustomize) and is documented separately on the Monitoring page.