OpenTofu
OpenTofu is the IaC tool for everything outside Kubernetes — VMs on Proxmox and Hetzner, the UniFi home network, the NetBird overlay, and the Scaleway backup bucket. 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
└── modules/
├── netbird/
│ ├── network/ ← reusable: declare a NetBird network + resource subnets
│ └── dns_record/ ← reusable: register a record in the shared zone
└── scaleway/
└── backup_bucket/ ← reusable: S3-compatible bucket scoped to one consumer
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.
netbird/
│
▼ (data sources)
┌─────────┼─────────┐
│ │ │
edge production home
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 |
modules/scaleway | scaleway/scaleway | API key |
Tokens are SOPS-encrypted in secrets.sops.yaml per environment; the operator decrypts them locally for tofu plan/apply and CI never sees them in plaintext.
Workflow
cd tofu/environment/<env>
tofu init # idempotent, refreshes provider plugins
tofu plan -out=plan # review the diff
tofu apply plan # apply only after review
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: OpenTofu's HTTP backend, with state files stored centrally and encrypted at rest.
- Locking: Backend-level lock prevents concurrent applies.
- Drift: A scheduled
tofu planjob (no apply) flags any out-of-band changes — useful when the UniFi controller's UI nudges a setting.
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 or via CI.
- 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.