GitOps-driven infrastructure for my own homelab
Find a file
Brewen Couaran 68b9a2b4fb
refactor(ansible): remove push-mirror automation from forge_bootstrap
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.
2026-08-19 16:34:22 +02:00
.github fix(pre-commit): remove schema check hook 2026-08-19 00:27:07 +02:00
.just feat(sops): split stores, decrypt inside ansible 2026-08-19 00:24:55 +02:00
ansible refactor(ansible): remove push-mirror automation from forge_bootstrap 2026-08-19 16:34:22 +02:00
config fix(brokkr): own data dirs to image uid 2026-08-19 14:01:30 +02:00
docs refactor(ansible): remove push-mirror automation from forge_bootstrap 2026-08-19 16:34:22 +02:00
flux feat(git)!: fail over between forgejo and codeberg 2026-08-18 22:04:48 +02:00
infra fix(git): probe before promote, drop github remote 2026-08-18 22:35:25 +02:00
nodes fix(brokkr): own data dirs to image uid 2026-08-19 14:01:30 +02:00
tofu feat(sops): split stores, decrypt inside ansible 2026-08-19 00:24:55 +02:00
.gitattributes feat(kenaz): bootstrap k0s + Flux for kenaz's cleaned-up reimplementation 2026-07-22 18:56:05 +02:00
.gitignore feat(monitoring): provision dashboards and sso 2026-08-12 21:54:31 +02:00
.gitleaks.toml feat(backup)!: replace velero with k8up 2026-08-12 18:27:16 +02:00
.pre-commit-config.yaml fix(pre-commit): remove schema check hook 2026-08-19 00:27:07 +02:00
.prettierignore feat!: replace OpenBao and Proton Pass with Infisical, Bitwarden SM and SOPS 2026-07-27 16:11:07 +02:00
.sops.yaml feat(sops): split stores, decrypt inside ansible 2026-08-19 00:24:55 +02:00
.sourceignore feat(brokkr): add standalone podman forge node 2026-08-17 19:17:03 +02:00
.trivyignore fix(trivy): copy the ignore file in at build time 2026-08-15 12:09:18 +02:00
.yamllint.yml feat!: replace OpenBao and Proton Pass with Infisical, Bitwarden SM and SOPS 2026-07-27 16:11:07 +02:00
justfile feat(just): add ag module for the node agent 2026-08-18 23:25:37 +02:00
LICENSE-APACHE init: first commit 2026-07-22 11:48:40 +02:00
LICENSE-MIT init: first commit 2026-07-22 11:48:40 +02:00
mise.toml feat(postgres): host linkwarden on shared instance 2026-08-15 20:10:08 +02:00
README.md fix(git): probe before promote, drop github remote 2026-08-18 22:35:25 +02:00

Codeberg Mirror License

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
Fedora k3s Flux cert-manager VictoriaMetrics Grafana NetBird Traefik Bunny Backblaze B2 Infisical Proton Pass Ansible OpenTofu just mise Renovate Trivy Actions

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:

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:

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.