vibe > Guidebooks > Factory provisioning > OpenTofu
OpenTofu — factory provisioning
Note
Status: ✅ active · Last Updated: 2026-06-23 Upstream: Factory provisioning hub · Lab ecosystem · 01 factory Downstream: factory iac · postgres iac · CI apply flow Related: Secrets & Vault · Storage & recovery · Naming conventions · ADR-0001 safe prod-like environment
OpenTofu is the declarative half of the factory: it provisions everything that lives outside the K3s cluster — Gitea repos & CI users, Vault policies, Cloudflare DNS, OVH domains, a GCS backup bucket, and the in-cluster PostgreSQL roles/databases. The imperative half (the cluster itself) is built by Ansible.
OpenTofu is pinned to 1.8.2 in CI (OPENTOFU_VERSION).
Two independent state roots
There are two separate Terraform/OpenTofu roots, each with its own backend.tf, its own GCS state prefix, its own provider set, and its own CI workflow. They never share state and can be applied independently.
| Root | Code path | State backend (GCS) | Triggered by |
|---|---|---|---|
| factory iac | iac/ |
gs://arcodange-tf/factory/main |
changes under iac/** → .gitea/workflows/iac.yaml |
| postgres iac | postgres/iac/ |
gs://arcodange-tf/factory/postgres |
changes under postgres/** → .gitea/workflows/postgres.yaml |
Note
Both roots share the same GCS bucket (
arcodange-tf) but live under distinct prefixes (factory/mainvsfactory/postgres), so their state objects never collide.
Providers
| Provider | Version | Endpoint / scope | Auth |
|---|---|---|---|
go-gitea/gitea |
0.6.0 |
https://gitea.arcodange.lab |
GITEA_TOKEN env var |
vault |
4.4.0 |
https://vault.arcodange.lab |
JWT login — mount gitea_jwt, role gitea_cicd |
google |
7.0.1 |
project arcodange, region US-EAST1 |
GOOGLE_CREDENTIALS (factory) / GOOGLE_BACKEND_CREDENTIALS (postgres backend) |
cloudflare/cloudflare |
~> 5 |
DNS / IAM | CLOUDFLARE_API_TOKEN env var |
ovh/ovh |
2.8.0 |
endpoint ovh-eu |
OVH_APPLICATION_KEY / OVH_APPLICATION_SECRET / OVH_CONSUMER_KEY |
cyrilgdn/postgresql |
1.24.0 |
192.168.1.202 (pi2), superuser |
POSTGRES_USERNAME / POSTGRES_PASSWORD (TF vars) |
The first five providers belong to the factory iac root (iac/providers.tf); the postgres iac root (postgres/iac/providers.tf) declares only postgresql + vault. Both roots configure the vault provider identically (JWT, mount gitea_jwt, role gitea_cicd).
The Vault-JWT auth model
Neither root carries long-lived Vault credentials. Instead CI mints a short-lived Gitea OIDC token and exchanges it for Vault access:
- A first job decodes the base64 secret
vault_oauth__sh_b64and runs it (base64 -d | bash), producing a Gitea OIDC JWT as a job output (gitea_vault_jwt). - That JWT is exported into the apply job as
TERRAFORM_VAULT_AUTH_JWT. - The
vaultprovider'sauth_login_jwtblock consumes it against mountgitea_jwt/ rolegitea_cicd, yielding a scoped Vault token used to read the per-provider secrets (Google creds, Gitea token, Cloudflare token, OVH app keys, Postgres creds).
See Secrets & Vault for the full Vault policy/mount design and CI apply flow for the job-by-job walkthrough.
CI apply flow
Both workflows share the same two-job shape: authenticate, then apply. The trigger paths differ (iac/** vs postgres/**) but the structure is identical.
%%{init: {'theme':'base', 'themeVariables': {'primaryColor':'#1f2937','primaryTextColor':'#f9fafb','lineColor':'#6b7280','fontSize':'14px'}}}%%
flowchart TD
classDef trigger fill:#1e3a5f,stroke:#3b82f6,color:#f9fafb;
classDef job fill:#1e4620,stroke:#22c55e,color:#f0fdf4;
classDef danger fill:#5f1e1e,stroke:#ef4444,color:#fef2f2;
push["push / PR touching<br/> iac/** or postgres/**"]:::trigger
auth["job: gitea_vault_auth<br/>decode vault_oauth__sh_b64<br/> mint Gitea OIDC JWT"]:::job
tofu["job: tofu<br/>read Vault secrets via JWT<br/> set provider env vars"]:::job
apply["dflook/terraform-apply@v1<br/> auto_approve: true"]:::danger
push --> auth
auth -- "gitea_vault_jwt output" --> tofu
tofu --> apply
- A push or PR that touches files under
iac/**(factory) orpostgres/**(postgres) starts the matching workflow;workflow_dispatchallows a manual run. - The
gitea_vault_authjob decodesvault_oauth__sh_b64and emits the Gitea OIDC JWT asgitea_vault_jwt. - The
tofujob (needs: gitea_vault_auth) setsTERRAFORM_VAULT_AUTH_JWTfrom that output, reads the provider secrets out of Vault, and prepares the homelab CA cert (VAULT_CACERT). - The job runs
dflook/terraform-apply@v1against the root'spath(iacorpostgres/iac) withauto_approve: true.
Caution
Applies are auto-approve. There is no manual plan-review gate — once a change to
iac/**orpostgres/**lands onmain, CI applies it to the real Gitea, Vault, Cloudflare, OVH, GCS, and PostgreSQL targets without further confirmation. Treat every merge as a production change and review the diff before merging, not after. This trade-off is recorded in ADR-0001 · safe prod-like environment.
Index
| Page | Covers | State |
|---|---|---|
| factory iac | iac/ root — Gitea, Vault, Google/GCS backup, Cloudflare, OVH |
✅ |
| postgres iac | postgres/iac/ root — PostgreSQL roles & databases on pi2 |
✅ |
| CI apply flow | Both Gitea workflows, the Vault-JWT exchange, auto-approve apply | ✅ |