Inside the Lab: A GitOps Homelab That Runs Itself (and a Telegram Bot Named Hermes)
I have one rule in my homelab: if I fixed something by SSHing in and didn't write it down, it didn't happen. Everything that runs here is declared in a Git repository, and a controller called Flux is responsible for making reality match that repo — continuously, whether I'm watching or not.
This is a tour of how that actually works: the GitOps loop, how secrets stay out of Git, how things reach the internet without opening a single port, and the part that makes people raise an eyebrow — Hermes, a Telegram bot that can SSH into my PC and run things for me. (Fun fact: the blog you're reading was built and shipped through this exact pipeline. I drove an AI agent to write the theme, and pushed it like everything else.)
the shape of it
One k3s cluster. One Git repo. A controller reconciling the two every minute.
git push
|
GitHub (main) --webhook--> Flux (reconciles every ~1m, instantly on push)
|
v
+------------------------------+
| k3s cluster |
| infrastructure -> secrets -> |
| configs -> apps |
+------------------------------+The whole platform is one repo. I don't kubectl apply anything by hand — I commit, and Flux takes it from there.
git is the only source of truth
Flux watches a single branch and applies the cluster as a set of layered Kustomizations. The ordering matters: the infrastructure layer (cert-manager, Traefik, Vault, databases) has to be healthy before the secrets layer, which has to exist before configs, which come before the apps. Flux's dependsOn enforces exactly that:
# clusters/k3s-home/apps.yaml
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps
namespace: flux-system
spec:
interval: 10m
path: ./apps
prune: true # delete what's no longer in Git
sourceRef:
kind: GitRepository
name: flux-system
dependsOn:
- name: infrastructure
- name: configsPolling every 10 minutes is fine, but I'm impatient. A Flux receiver sits behind my tunnel on a hashed webhook URL; GitHub pings it on every push and the relevant Kustomization reconciles in about a second. Push, blink, it's live.
secrets, without secrets in Git
Two layers handle this. SOPS (with an age key) encrypts the rare secret that genuinely has to live in the repo. Everything else runs through Vault + the External Secrets Operator (ESO). Vault holds the truth in a KV-v2 store; ESO authenticates with an AppRole and syncs those values into native Kubernetes Secrets on a schedule. My manifests only ever reference a path — never a value:
# apps/ghost/external-secrets.yaml
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: mariadb-ghost
namespace: ghost
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: vault
target:
name: mariadb-ghost # the k8s Secret ESO creates
creationPolicy: Owner
data:
- secretKey: password
remoteRef:
key: secret/apps/ghost
property: passwordThe repo is public-safe by construction: there are no plaintext credentials in it, anywhere. Rotate a value in Vault and ESO quietly refreshes the cluster.
getting in without opening a port
My router forwards nothing. Inbound firewall holes: zero. Public traffic arrives through a Cloudflare Tunnel — an outbound-only connection from a cloudflared agent up to Cloudflare's edge, so there's no port to find and nothing to portscan. Inside the cluster, Traefik (handed a LAN IP by MetalLB) routes to each service, and cert-manager mints Let's Encrypt certificates over a DNS-01 challenge. Real domains, real TLS, no exposed surface.
keeping it fresh
I don't chase updates manually. Renovate watches every container image, Helm chart, and Flux component, then opens grouped pull requests overnight. Patches it can auto-merge; anything major it labels and leaves for me to read. Images are pinned by digest for reproducibility. The lab keeps itself current and hands me a changelog instead of a surprise.
meet hermes
Now the fun part. Hermes is an LLM agent running as a pod in the same cluster. I talk to it on Telegram; it talks to my PC over SSH. So I can be away from home, message a bot, and have it actually do things on my machine — check a process, kick a script, debug something — and reason about the output.
Telegram --> Hermes pod (k3s) --SSH over Tailscale--> my PC
^ |
+-------- output + LLM reasoning -------+This sounds reckless until you look at how it's fenced in:
- Telegram allowlist — only specific, hardcoded user IDs are accepted. A stranger who stumbles onto the bot gets nothing.
- SSH over Tailscale only — the pod can't reach my PC across the LAN; it can only ride my private tailnet, authenticating with an ed25519 key. No passwords, no public exposure.
- Least privilege — it logs in as a normal user (never root), the pod itself runs non-root, and the key is mounted read-only.
- Declarative like everything else — its Deployment, its secrets (via Vault + ESO), and its storage all live in Git, reconciled by Flux.
# apps/hermes/deployment.yaml (excerpt, values pulled from Vault)
env:
- name: TERMINAL_ENV
value: ssh
- name: TERMINAL_SSH_HOST # my PC's tailnet address
valueFrom: { secretKeyRef: { name: hermes-secrets, key: ssh-host } }
- name: TERMINAL_SSH_USER # a normal, non-root user
valueFrom: { secretKeyRef: { name: hermes-secrets, key: ssh-user } }
- name: TERMINAL_SSH_KEY
value: /etc/hermes-ssh/id_ed25519 # mounted read-only
- name: TELEGRAM_ALLOWED_USERS
valueFrom: { secretKeyRef: { name: hermes-secrets, key: telegram-allowed-users } }Under the hood it's LLM-driven through OpenRouter — a cheap reasoning model by default, swappable to something heavier with a /model command when I want better answers. It keeps a small memory file describing my environment so it knows what it's working with, and /new wipes the conversation when it gets stubborn. The whole thing costs me cents a month.
what this actually buys me
- Push to Git, it's live in seconds — the same way, every time, with no hand-tweaking to forget.
- Reproducible and auditable — my
git logis the change history of the entire platform. - Reachable from my pocket — I can check on, or fix, the lab from anywhere through a bot.
- A workbench for building — when I want something new, like this terminal theme, I point an AI agent at the same workflow and let it ship.
It's a lot of moving parts for a "homelab." But none of them move unless Git says so — and that's the entire point.
$ — Leo