Test exercise in setting up a kubernetes cluster with helm charts, controlled by flux all managed by terraform
|
Some checks failed
build-hello-app / build-and-push (push) Failing after 20s
FORGEJO_TOKEN now has package:write scope, and FORGEJO_USER/FORGEJO_ORG
have been moved from repo Secrets to repo Variables where ${{ vars.X }}
actually reads from.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4YNpuC2bgT224suQLLJ7B
|
||
|---|---|---|
| .forgejo/workflows | ||
| apps | ||
| clusters/homelab | ||
| docs | ||
| scripts | ||
| terraform | ||
| .gitignore | ||
| README.md | ||
Homelab Platform — Terraform + k3s + Flux on Forgejo
Learning project: provision VMs on the T630 with OpenTofu/Terraform, bootstrap a k3s cluster on them, and manage everything after that point through GitOps (Flux) synced from a Forgejo repo, with Forgejo Actions handling CI. The T630 keeps running its other self-hosted services throughout — this installs as ordinary KVM/libvirt packages next to them, sized deliberately small (3 VMs, 2 vCPU/2GB RAM each), not a hypervisor OS taking over the box.
A four-stage tutorial, each stage building on the last:
docs/01-bootstrap.md— one-time host setup: KVM/libvirt, thek8suser, pushing this repo to Forgejo.docs/02-k3s.md— build one VM by hand, get a real k3s cluster running on it. No Terraform yet.docs/03-flux.md— bootstrap Flux against that cluster, deploy every app inapps/through it (Helm-managed and plain-manifest alike), wire up the hello-app CI/image-automation loop.docs/04-tofu.md— graduate from the one hand-built VM to a Terraform/OpenTofu-provisioned 3-node cluster, and point the same Flux config at it. The harder stage — its HCL is schema-verified against the pinned provider version but not yet proven with a realtofu apply(see "Where this track actually stands" near the top of that doc) — worth finishing properly, but don't block on it; stages 2-3 already get you a complete, working GitOps loop without it.
The loop
OpenTofu (terraform/)
-> provisions VMs on the T630 via KVM/libvirt (qemu:///system)
-> cloud-init installs k3s (1 server + 2 agents)
-> VMs live on an isolated private network (10.20.30.0/24), reachable
only from the T630 itself
Forgejo repo (this repo)
-> clusters/homelab/ = Flux config (what Flux itself watches)
-> apps/ = workloads Flux deploys
-> .forgejo/workflows/ = CI (terraform plan, image builds)
Flux (running in the cluster)
-> watches this repo
-> applies clusters/homelab/** and apps/** to k3s
Caddy (Podman, existing) is the public front door for boglabob.com, but only
for the low-stakes apps — Headlamp and the k3s API server stay off it:
git.boglabob.com -> Forgejo (existing)
podinfo.boglabob.com -> Traefik (k3s ingress) -> podinfo
hello.boglabob.com -> Traefik (k3s ingress) -> hello-app
[Headlamp] -> kubectl port-forward only, never a public hostname
[k3s API, port 6443] -> reachable only via the T630 (SSH tunnel or run
kubectl there directly), no proxy, no public port
see docs/Caddyfile.example, docs/03-flux.md step 5 (Headlamp), and
docs/04-tofu.md step 5 (LAN/remote kubectl access)
Why it's structured this way
Each layer below only knows about the one directly beneath it — that's deliberate, not incidental, and it's what makes the pieces independently replaceable:
- Terraform provisions VMs, nothing more. It doesn't install k3s itself
or configure Flux. Its job ends at "three VMs exist, on this network, with
this cloud-init data attached." That's what makes
docs/02-k3s.mdpossible at all — a singlevirt-installVM can stand in for the whole Terraform layer, because everything above it only cares that some VM with ak3suser and a working kubeconfig exists, not how it got there — which is exactly why Terraform is stage 4, not stage 1: everything above it gets learned and proven out on one hand-built VM first. - cloud-init installs k3s, nothing about Flux. It runs
get.k3s.ioand stages a kubeconfig — full stop. Flux isn't baked into the image or the cloud-init data becauseflux bootstrapneeds to commit into this repo (the generatedclusters/homelab/flux-system/manifests), which only makes sense as a deliberate, one-time, human-run command against a cluster that already exists and can reach Forgejo — not something to automate blindly on every VM boot. flux bootstrapis a one-time bridge, not a recurring step. It's the only command in this whole project that goes API-server-side and repo-side in one shot: it installs the controllers, and it writesclusters/homelab/flux-system/back into git. After that one run, the cluster is entirely git-driven — rerunningflux bootstrapagainst the same repo is idempotent (safe if you need to recover a cluster, or to point the same config at a new cluster — seedocs/04-tofu.mdstep 4), but nothing after that first run ever needs a human to runkubectl applyagain.clusters/homelab/apps.yamlfans out on purpose, instead of one giant Kustomization pointing atapps/. Each app gets its own FluxKustomizationobject (seeclusters/homelab/apps.yaml) with its ownintervaland its ownprune: true. That means a bad manifest inapps/hello-appcan't blockapps/podinfofrom reconciling — each app's sync loop is independent, and you can watch/debug them individually withflux get kustomization <name>instead of one opaque blob.- Helm apps (podinfo, headlamp) vs. plain manifests (hello-app).
podinfo and headlamp are third-party charts — Flux's
HelmRepository+HelmReleasepair is what lets Flux track and upgrade an upstream chart without this repo vendoring it.hello-appis this project's own code, so it's plain Kubernetes YAML (Deployment/Service/Ingress) — there's no chart to track, and plain manifests are simpler to diff/read for something this small. - Image automation exists only for
hello-app, not podinfo/headlamp.ImageRepository/ImagePolicy/ImageUpdateAutomation(apps/hello-app/image-automation.yaml) close the loop from "CI pushed a new image tag" to "the cluster is running it" — that's only relevant for an app whose image this repo's own CI builds. podinfo/headlamp pin a chart version range instead (>=6.0.0,>=0.40.0) and get upgraded by bumping that constraint by hand, since there's no CI producing new tags for them to watch.
Directory layout
terraform/— OpenTofu config that provisions the k3s VMs via KVM/libvirt directly on the T630 (no separate hypervisor OS, no VM template step — the cloud image is pulled straight from its URL).terraform/cloud-init/— cloud-init templates that install k3s server/agent on first boot.clusters/homelab/— Flux's own config for this cluster (populated byflux bootstrap, see docs/03-flux.md step 2).apps/podinfo/— first GitOps app: a HelmRelease for the standard Flux demo app (podinfo). No CI needed — good for validating the Flux sync loop works.apps/hello-app/— capstone app, and the public showcase piece athello.boglabob.com: a one-page site (source + Dockerfile) built by Forgejo Actions, pushed to Forgejo's container registry, deployed via a FluxImagePolicy/ImageUpdateAutomationso new pushes to main roll out automatically. The page itself shows the live commit SHA and deploy timestamp injected by CI, as proof the pipeline is really running rather than a static screenshot.apps/headlamp/— a Kubernetes UI (replacing the now-unmaintained Kubernetes Dashboard), with a cluster-admin token (apps/headlamp/rbac.yaml). No ingress — access is viakubectl port-forwardonly (see docs/03-flux.md step 5)..forgejo/workflows/— CI: terraform validate/plan on PRs, build+push hello-app image on merge to main, via kaniko under a rootless Podman runner (no docker.sock, no sudo — see docs/03-flux.md step 6).docs/Caddyfile.example— the reverse-proxy blocks for podinfo/hello-app to add to your existing Caddy (Podman) config.
Prerequisites (see docs/01-bootstrap.md onward for the full walkthrough)
qemu-kvm/libvirtinstalled on the T630 (alongside its existing services)- Forgejo instance reachable, with this repo pushed to it and Actions enabled
kubectl,flux,tofuCLI — run on the T630 itself as the unprivilegedk8suser (simplest, since the VMs' network is only reachable from there)
Milestones
- KVM/libvirt installed on the T630,
k8suser created (docs/01-bootstrap.md) - One hand-built VM, k3s running on it, kubectl talking to it (
docs/02-k3s.md) flux bootstrapagainst this Forgejo repo — podinfo/headlamp/hello-app deploy with no manualkubectl apply(docs/03-flux.md)- Wire
.forgejo/workflows/build-hello-app.yml— push a code change toapps/hello-app/src, watch CI build → registry → Flux auto-deploy (docs/03-flux.mdstep 7) tofu apply— 3 VMs come up, k3s cluster forms, same Flux config re-bootstrapped against it (docs/04-tofu.md)- Stretch: sealed-secrets/SOPS, kube-prometheus-stack, cert-manager