The SFI Platform
Every system in this section runs on infrastructure Streetfortress operates itself, and that platform is the longest-running project of the lot. It has moved through three distinct eras — Ansible-managed Ubuntu hosts, Compose stacks behind Caddy, and now NixOS hosts running Flux-driven GitOps on k3s — and each transition was forced by a specific failure of the previous era, not by fashion. This document is the case study of that evolution; the technique documents it links to are the reusable pieces.
SFI project: ongoing. The current migration lives in the
deploymentsmonorepo; snippets are lifted fromfleet/clusters/luxor/and.sops.yamlat current HEAD.
Three eras, three lessons
Era one: Ansible over Ubuntu. Playbooks provisioned VMs, a WireGuard mesh connected them (network-isolation), and configuration lived in inventory files. It worked until it drifted: playbooks describe how to change a host, not what a host is, so every manually-patched package and hand-edited config accumulated as unrecorded state. Rebuilding a host meant archaeology.
Era two: Compose + Caddy. Application deployment collapsed to a pattern
still documented in simple-container-deployment: a justfile, a
compose.yaml, sops-encrypted .env files, Caddy for TLS. This fixed the
application story — deployments became declarative and portable — but the
hosts underneath were still mutable snowflakes, and every service addition
meant SSH.
Era three: NixOS + k3s + Flux. The current migration makes the host itself declarative. A machine is a flake output; its secrets identity is seeded at provisioning; everything above the OS reconciles from git. The goal is stated as a test: a host can be rebuilt from its flake and its workloads restored from git and backups, hands-off. Host-level reasoning is in host-config; this document covers how the pieces compose.
Git as the only path to production
Flux itself is installed declaratively — the flux-operator reads a
FluxInstance resource rather than accumulating CLI-generated bootstrap
commits:
apiVersion: fluxcd.controlplane.io/v1
kind: FluxInstance
metadata:
name: flux
namespace: flux-system
spec:
distribution:
version: 2.x
registry: ghcr.io/fluxcd
components:
- source-controller
- kustomize-controller
- helm-controller
- notification-controller
sync:
kind: GitRepository
url: ssh://git@gitea.zen.lofi:30022/sfi/deployments.git
ref: refs/heads/main
path: fleet/clusters/luxor # each cluster reconciles only its own path
The path field is the multi-cluster strategy. One monorepo holds every
cluster's desired state; each cluster's Flux reconciles only
fleet/clusters/<name>, which composes shared infrastructure with
cluster-specific apps:
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps
namespace: flux-system
spec:
dependsOn:
- name: infrastructure # operators reconcile before workloads
path: ./fleet/apps/luxor
prune: true # deleting from git deletes from the cluster
decryption:
provider: sops
secretRef:
name: sops-age
The monorepo trade is worth naming: one repo means atomic cross-layer
changes, one .sops.yaml, one Renovate configuration — and it costs nothing
at runtime because path-scoping keeps each cluster's blast radius to its own
directory. Flux mechanics are covered in flux; the supply-chain
reasoning for the self-hosted git and OCI registry is in
self-hosted-artifacts and supply-chain.
Secrets: scoped by construction
Secrets are sops-encrypted in git, and the encryption rules enforce isolation structurally — a cluster's key can only decrypt its own secrets:
# .sops.yaml — path-scoped per cluster
creation_rules:
- path_regex: fleet/(clusters|apps)/luxor/.*
encrypted_regex: ^(data|stringData)$
age: >-
age1rgr7..., # admin key
age142ql... # luxor's cluster key — private half lives only on luxor
# fallback: admin-only, whole file
- path_regex: .*
age: age1rgr7...
Each host's age identity is seeded at provisioning time (the pattern in pre-seeded-identity), which is what makes bootstrap hands-off: the freshly-built host can already decrypt its own secrets, so Flux's first reconcile succeeds with no one pasting keys. A compromised staging cluster cannot read production's secrets because it never possessed a key that could. Application-level handling follows sops-application-secrets.
Restore drills as phase gates
The migration is sequenced in phases, and the discipline worth copying is the gate between them: nothing is adopted into GitOps until its backups have been restored, actually, from the latest backup. The greenfield cluster (luxor) rebuilds the existing stack in parallel while the original host stays frozen; only after a successful restore drill of the git service itself does the original get adopted. Postgres runs under CloudNativePG with layered physical and logical backups — the strategy and its reasoning are in backup-strategies.
This ordering exists because the failure mode it prevents is the expensive kind: migrating the source of truth onto a platform whose recovery path has never been exercised. A backup that has not been restored is a hypothesis.
Honest state
This is a migration in progress, not a finished platform. The git server is a single point of failure until its adoption phase completes (mitigated by an external mirror and offsite backups); observability of the fleet is a later phase; and the original host still runs the era-two Compose stack while parity is proven. The register lists this unit as Active Migration because that is what it is. The direction, though, is the point: every phase narrows the gap between "the infrastructure" and "what git says the infrastructure is."