corvus
ADR 0001 accepted

Repo split and cluster shape for the corvus k3s migration

Context

The s5p8/infra GitLab group holds 12 projects, each a docker-compose stack run as a systemd unit with a watchdog, glued together by shared docker networks (nginx-proxy, glances, events) and ./data bind mounts on a single host (a ThinkPad T430 running Omarchy). Those services are moving to a k3s cluster under a new identity, corvus, with the domain corvus.observer held in Cloudflare.

The question was how to split the pieces needed to build and run that cluster across git repos, while keeping the rule that each service is its own namespace with its own chart and its own deployment cycle.

Decisions

One group, one repo per concern

One GitLab group, corvus. platform is the base of everything, calqued from patagoniamove/platform and throwlines/platform: it holds what the cluster needs before any service exists and what no service may install for itself. Every namespace is its own repo, with the same name as today, transferred from s5p8/infra one at a time as each service cuts over, history kept. A service repo carries no infrastructure code: a DNS record, a tunnel hostname or a CI variable is declared in platform, which is the single place where “this service exists in the cluster” and “this service is public” are written down.

Folder of platformWhat it owns
iso/The archiso profile that installs Arch unattended on a node.
ansible/The static inventory and the roles that turn an installed node into a k3s server or agent, with Longhorn’s prerequisites. Replaces the .systemd/ directories of today’s service repos.
infra/The group’s only OpenTofu root: the Cloudflare zone, tunnel hostnames and Access policies, the UniFi network, the GitLab projects and CI variables. Remote state in R2, applied locally with credentials in a git-ignored .env.infra.
cluster/The Flux bootstrap and the cluster-wide deployables (storage, load balancer, certificates, ingress, monitoring, the tunnel), plus one GitRepository and Kustomization per service repo.

The layout of a service repo

Every service repo has the same shape, so a chart, a deploy and a CI job are found in the same place in all of them:

chart/                hand-written Helm chart; values.yaml has no secrets
deploy/
  helmrelease.yaml    Flux HelmRelease
  secrets.enc.yaml    SOPS-encrypted Secrets
  kustomization.yaml
.sops.yaml            age public key
.gitlab-ci.yml        lint + helm template on MR; build/push image on main when a Dockerfile exists
.ahoy.yml             operator helpers

The namespace is created by the service’s chart and named after the repo.

Retired and parked

revproxy and glances are retired: Traefik’s ingress replaces the first, kube-prometheus-stack replaces the second and its dashboards move to the services that own them. nvr and ollama are parked, out of scope for now: the first needs a GPU, NFS and /mnt/purple, the second has no hardware to run on.

Nodes

HostRoleNotes
ThinkCentre M715q #1, #2, #3k3s server (embedded etcd)A10 APU, 8 GB, 256 GB NVMe. Longhorn.
ThinkCentre M715q #4k3s agentLonghorn.
ThinkPad T430 (Arch)k3s agentLonghorn.
ThinkPad T430 (Omarchy)k3s agent, laterCurrent host. Joins after the last service moves off it.

Three servers because etcd needs an odd quorum; two is strictly worse than one. Every node runs workloads; “server” only adds the control plane.

Parked: four Raspberry Pi 4 (arm64, would join as tainted edge agents, no Longhorn while on SD cards), MacBook Air 2013 (would join as a plain x86 agent once wired), Raspberry Pi 2B (never, armv7).

Deployment

Pull-based with FluxCD. Merging to main in a service repo is the deploy. Chosen over Argo CD because HelmRelease runs real Helm, SOPS decryption is native, the one-GitRepository-per-repo model matches the namespace-per-repo rule, and image automation closes the loop for repos that build images without a runner on the LAN.

Upstream image tags are pinned in values.yaml and bumped by Renovate, configured once in platform.

Secrets

Storage

Longhorn as the only storage class, replicated across the five x86 nodes, with the Synology NAS as the NFS backup target. Chosen over synology-csi so that a NAS reboot does not stall DNS, the UniFi controller and Home Assistant. k3s local-path was considered but it pins every stateful service to one node.

Networking and exposure

Monitoring

platform/cluster/ installs kube-prometheus-stack and Loki + Alloy. Grafana is LAN-only. The per-service contract is a ServiceMonitor and a dashboard ConfigMap with the Grafana sidecar label, both in the service’s chart. Logs need nothing from services. Alert routing is deferred.

Migration order

  1. platform: ISO, Ansible, k3s on the five boxes, Flux, Longhorn, MetalLB, cert-manager, Traefik, monitoring. Verified with a throwaway whoami ingress.
  2. tunnels: lowest risk, and unlocks remote admin.
  3. events: proves MetalLB on a non-HTTP port.
  4. bookmarks, home, spacebot: prove Longhorn and Ingress with state.
  5. network: proves the UDP LoadBalancer. Old controller stays up until the APs are healthy on the new one, then set-inform and shut it down. Then tofu import the LAN into platform/infra.
  6. hassio.
  7. dns, last: new pihole on its MetalLB IP in parallel, test, flip the router’s DHCP DNS, keep the old one a day.
  8. The Omarchy T430 joins the cluster.

Each step transfers that project from s5p8/infra to corvus when it starts.

Consequences