4 min read @leo

Inside the Lab: A GitOps Homelab That Runs Itself (and a Telegram Bot Named Hermes)

How my whole homelab is declared in one Git repo and reconciled by Flux — secrets via Vault, zero open ports via Cloudflare Tunnel, and Hermes, a Telegram bot that SSHes into my PC.
Neatly bundled network cables in a server rack
Photo by Taylor Vick on Unsplash

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: configs

Polling 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: password

The 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 log is 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