OpenTofu
OpenTofu is the IaC tool for everything outside Kubernetes — VMs on Proxmox and Hetzner, the UniFi home network, the NetBird overlay, and the all-inkl mail accounts of the public side projects. Inside Kubernetes, Flux takes over.
The split is deliberate: anything Kubernetes-native goes through GitOps, anything outside goes through OpenTofu, no overlap.
Why OpenTofu (not Terraform)
OpenTofu is the open-source fork that took over after the Terraform license switch. Functionally a drop-in: same HCL, same provider ecosystem, same state format. The reasons here:
- License clarity. OpenTofu is BSL-free. The homelab is a personal repo with
tofu/modules/that could conceivably be reused elsewhere; keeping it on a permissive licence keeps that door open. - Native state encryption. OpenTofu added it before Terraform; combined with SOPS-managed secrets, no plaintext credentials ever land on disk.
- No vendor friction — every provider used here works the same on both, so swapping back is always a
s/terraform/tofu/gaway.
Repo layout
tofu/
├── environment/
│ ├── tofu/ ← bootstrap state for OpenTofu itself (backend, encryption keys)
│ ├── netbird/ ← shared identity, groups, DNS zone, cross-cutting policies
│ ├── edge/ ← Hetzner Cloud — VMs, VPC, firewalls, floating IP
│ ├── production/ ← Proxmox — VMs, LXCs, NICs, DNS
│ ├── home/ ← UniFi — gateway, switch, VLANs, firewall
│ ├── johnny-projects/ ← all-inkl mail accounts per public side project (bucketlist, cairn, …)
│ └── demo/ ← throwaway Hetzner demo server + all-inkl records
└── modules/
└── netbird/
├── network/ ← reusable: declare a NetBird network + resource subnets
└── dns_record/ ← reusable: register a record in the shared zone
How environments compose
netbird/ is the single owner of cross-cutting state — groups, users, tokens, DNS zone. Every other environment consumes that state via data lookups. This means a per-site apply can never accidentally drift the shared identity layer.
A new site (say, a colo box) follows the same pattern: a fresh tofu/environment/<site> directory, declaring its own resources and consuming what it needs from netbird/.
Providers
| Environment | Provider | Auth |
|---|---|---|
netbird | netbirdio/netbird | tofu_provider service-user token |
edge | hetznercloud/hcloud | API token |
production | bpg/proxmox | API token |
home | ubiquiti-community/unifi | controller user/pass |
johnny-projects, demo | johnnycube/allinkl (plus hcloud for demo) | API credentials |
Credentials are SOPS-encrypted per environment in two files: .env.sops (the state-backend key, as environment variables) and .auto.tfvars.sops (provider tokens and the state passphrase). They are decrypted on the workstation into the gitignored .env and .auto.tfvars; nothing runs OpenTofu in CI, so the plaintext never leaves that machine. See SOPS for the key that protects them.
Workflow
cd tofu/environment/<env>
./tofu.sh init # idempotent, refreshes provider plugins
./tofu.sh plan -out=plan # review the diff
./tofu.sh apply plan # apply only after review
tofu.sh is a thin wrapper in every environment that loads .env and then calls tofu with the same arguments. Apply only after a plan review — never apply without an explicit plan file. This is enforced by repo convention rather than tooling, but it prevents the worst-case "apply nothing-then-everything" footgun when a backend connection is flaky.
State
- Backend: S3, in the bucket
tofu-state-558a4a64on Hetzner Object Storage (hel1), one<env>.tfstateobject per environment. - Encryption: OpenTofu's client-side state encryption (PBKDF2 key from a passphrase, AES-GCM), so the bucket only ever holds ciphertext.
- Locking: none is configured. There is a single operator; don't run two applies against the same environment at once.
- Drift: found by running
./tofu.sh planby hand. Nothing schedules it. - In-cluster workspaces: the per-app workspaces that Crossplane runs keep their state in the same kind of backend, which is what lets a rebuilt cluster adopt existing buckets and keys instead of recreating them.
Adding a new resource — the loop
- Declare the resource in the right
tofu/environment/<env>/(or pull a module). - Plan locally; review the diff.
- Apply locally.
- Document the new resource in the corresponding docusaurus page (Proxmox / Hetzner / UniFi / NetBird).
- If it's a new pattern, extract a module under
tofu/modules/.
Operational notes
- Service-user tokens in NetBird are 1-year. Rotation is a tofu apply.
- Cloud firewalls on Hetzner are label-selected — easy to grow, easy to audit.
bpg/proxmoxassumes a clean cluster cert chain; replace the Proxmox CA → adjust the provider trust before applying.- State migrations between environments are not supported automatically; if you need to move a resource between envs, use
tofu state mvbetween dirs.