Skip to main content

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/g away.

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

EnvironmentProviderAuth
netbirdnetbirdio/netbirdtofu_provider service-user token
edgehetznercloud/hcloudAPI token
productionbpg/proxmoxAPI token
homeubiquiti-community/unificontroller user/pass
modules/scalewayscaleway/scalewayAPI 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 plan job (no apply) flags any out-of-band changes — useful when the UniFi controller's UI nudges a setting.

Adding a new resource — the loop

  1. Declare the resource in the right tofu/environment/<env>/ (or pull a module).
  2. Plan locally; review the diff.
  3. Apply locally or via CI.
  4. Document the new resource in the corresponding docusaurus page (Proxmox / Hetzner / UniFi / NetBird).
  5. 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/proxmox assumes 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 mv between dirs.

Where to look next