The role registered Codeberg push mirrors via the Forgejo API, which blocked cold bootstraps: the repository is created by hand only after the role has already had to run to make Forgejo answer. The mirror is now created manually in Forgejo's UI, so the four mirror tasks and their config (forge_bootstrap_repo, forge_bootstrap_mirrors) are gone. The Woodpecker OIDC client was never automated and stays manual: neither community Forgejo Terraform provider exposes an oauth2_application resource, so tofu/forgejo cannot create it. Docs updated to describe manual mirror creation/rotation; setup step 11 gains a 4th sub-step for the mirror. |
||
|---|---|---|
| .github | ||
| .just | ||
| ansible | ||
| config | ||
| docs | ||
| flux | ||
| infra | ||
| nodes | ||
| tofu | ||
| .gitattributes | ||
| .gitignore | ||
| .gitleaks.toml | ||
| .pre-commit-config.yaml | ||
| .prettierignore | ||
| .sops.yaml | ||
| .sourceignore | ||
| .trivyignore | ||
| .yamllint.yml | ||
| justfile | ||
| LICENSE-APACHE | ||
| LICENSE-MIT | ||
| mise.toml | ||
| README.md | ||
futhark
The code for my GitOps-driven homelab: Ansible provisions the hosts, Flux reconciles a k3s cluster from this repository, and OpenTofu manages what lives outside it.
Documentation: source in docs/, built with just docs build. The published copy is
offline while CI moves off GitHub.
| OS | Cluster | Networking | Cloud | Tooling |
|---|---|---|---|---|
Overview
Two Fedora nodes joined over a NetBird mesh
run one k3s cluster. kenaz runs the k3s server and carries my apps. ogma is an agent and the
cluster's only public entrypoint, so both Traefiks and Pocket ID are pinned to it with a
nodeSelector. Neither node is ever addressed by an address I store somewhere; they talk over
their mesh DNS names.
The tree splits along three planes, and almost every question I have about this repository resolves to "which plane owns this?":
| Plane | Owns | Tool | Where |
|---|---|---|---|
| Host | The machines: users, SSH, firewall, mesh join, the k3s install itself | Ansible | ansible/ |
| Cluster | Everything reconcilable from git: controllers, apps, namespaces, policy | Flux | flux/, infra/, nodes/ |
| Cloud | Provider APIs no Kustomization can express: DNS, OIDC clients, mesh policy | OpenTofu | tofu/ |
Each plane hands off to the next exactly once, and nothing reaches back the other way. Ansible installs k3s and bootstraps Flux, then stops touching the cluster. After that Flux is the only writer, running as a FluxInstance that reconciles this repository over SSH. The full picture is in the documentation index.
I wanted the whole thing to be rebuildable from a cold start, so the bootstrap is written down as twelve re-runnable steps in Cold bootstrap rather than living in my head.
What runs
Cluster infrastructure lives in infra/, one directory per component, and comes up
roughly in this order:
- The Infisical operator, which pulls per-app runtime secrets into Kubernetes Secrets.
- Pocket ID as the OIDC provider, with oauth2-proxy behind a Traefik forward-auth middleware, so anything without its own login still gets one.
- cert-manager with a single
letsencrypt-bunnyClusterIssuer, solving Let's Encrypt DNS-01 through Bunny. - Two Traefiks:
traefik-edgeon the public IngressClassedge, andtraefik-internaloninternal, reachable only over the mesh. Most things only need the second one. - Storage: the k3s
local-pathclass for anything that should stay on the node, plus csi-driver-rclone for the encrypted rclone remotes. - K8up writing restic snapshots to Backblaze B2.
- VictoriaMetrics, VictoriaLogs and Grafana, with Gatus for uptime and Glance as the homepage.
- trivy-operator, scanning what is already running.
My own apps live in nodes/, one directory per node, so a node's workload is obvious
from the tree. Cluster infrastructure and
Node apps go through both.
One rule covers versions everywhere: nothing floats. Images pin tag@sha256:..., charts pin an
exact MAJOR.MINOR.PATCH, and every bump arrives as a
Renovate pull request I can read before it lands.
Renovate is self-hosted rather than the Mend-hosted app, so no third party holds write access
here. It is paused while CI moves off GitHub, so bumps are manual for now.
Security and networking
No credential is ever committed in the clear, and neither is any identifying value, because this repository is public. Three stores split the work by what a value can do rather than by who consumes it:
- SOPS for values that identify but grant nothing, such as node addresses and the domain. Encrypted in-repo, read by Ansible, OpenTofu and Flux.
- Proton Pass for anything that could bootstrap or re-key the system. Never committed, and the cluster holds no Proton Pass credential at all, so a cluster compromise cannot reach the keys that rebuild it. That boundary rests on absence rather than on a console-side grant I could misconfigure.
- Infisical for per-app runtime secrets, reaching pods through the Infisical operator.
Secrets documents the boundary and the checklist for adding one.
Every namespace starts default-deny. Traffic is opened back up by composing explicit bridges from
the templates in infra/policies/namespaces/_templates/, one directory per namespace, so nothing
talks to anything by accident. Namespaces also carry futk.eu/tier and futk.eu/node labels, and
those labels are load-bearing rather than decorative: a ValidatingAdmissionPolicy reads them to
decide which Infisical path a namespace may pull from. See
Network policy.
Every container image is scanned by Trivy: before a merge in CI, with the
findings published to this repository's Security tab, and continuously in the cluster by
trivy-operator. Both halves share the same .trivyignore, so an exception I write
once holds in both places. just sec reports ranks what is live, and just sec scan <image> lets
me check a pin before committing it.
gitleaks runs twice, once as a
pre-commit hook and again over the whole tree in CI, and
GitGuardian watches the repository for anything that slips past it.
On the hosts, Ansible applies ssh_harden, fail2ban, firewalld
and firewall_ingress.
Monitoring
I run a small stack rather than a complete one, on the theory that I will only act on what I actually look at:
- Metrics: VictoriaMetrics single, scraped by vmagent, with kube-state-metrics, node-exporter and a custom egress exporter deployed by Ansible.
- Logs: VictoriaLogs single, fed by Fluent Bit.
- Dashboards and alerts: Grafana, logging in through Pocket ID. Dashboards and unified alerting
rules are committed as JSON under
infra/monitoring/, so a dashboard I break is a revert away. - Uptime: Gatus.
- Homepage: Glance, pulling Flux state, mesh peers, backups, certificates and vulnerabilities into one page.
Sizing for the whole stack is centralised in infra/substitutions/ and
substituted in by Flux, so retention and limits move in one file instead of ten.
Cloud dependencies
I self-host what I reasonably can. What is left:
| Provider | Use | Cost |
|---|---|---|
| Bunny | DNS, and the cert-manager webhook that answers the DNS-01 challenge | $1/mo |
| Backblaze B2 | Restic backup target, and the OpenTofu state backend | $6.95/TB/mo |
| NetBird | The mesh the nodes join and are addressed on | Free |
| Infisical | Per-app runtime secrets, on the EU cloud | Free |
| Proton Pass | Bootstrap and re-key credentials, on operator machines only | ~$120/y |
| Let's Encrypt | Certificates | Free |
| Codeberg | The backup remote every plane fails over to | Free |
| Total: ~$11/mo plus B2 storage |
Getting started
This is not a template to clone. It is a working homelab with node names, a domain and a key hierarchy baked into it, and it will not come up as-is for anyone else. Read it for how the pieces fit rather than to run it. If a piece is useful, take that piece.
Everything I run goes through just:
just help
Recipes are grouped into eight modules, each with its own just <module> help:
| Module | What it drives |
|---|---|
ops |
The operator machine: dependencies, hooks, SOPS keys, Proton Pass, mesh |
ans |
Ansible playbooks: host setup, the k3s cluster, rendered secrets |
tf |
OpenTofu modules: init, plan, apply, adopt, validate |
fx |
Flux: sync state, HelmReleases, reconciles, controller logs |
ks |
The cluster: health, pods, logs, events, certificates, usage |
bak |
K8up: schedules, snapshots, on-demand backups, restores |
sec |
trivy-operator: vulnerability, config, RBAC and compliance reports |
docs |
This documentation: build and serve |
The toolchain is pinned in mise.toml and installed by just ops setup, so a second
operator machine matches this one exactly. The documentation is an
mdBook with D2 diagrams, built with
just docs build.
License
This project is licensed under either of the following, at your option:
- Apache License, Version 2.0, (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT License (LICENSE-MIT or http://opensource.org/licenses/MIT)
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this project by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.