Deep, code-grounded tree-docs guidebook under vibe/guidebooks/factory-provisioning/, explored from the actual playbooks/roles and tofu code: - Hub: the two provisioning engines (operator-run Ansible vs CI-applied OpenTofu), a green-field bring-up flow, master index, maintenance rule. - ansible/ sub-tree: ordered pages 01-system .. 06-recover, an inventory & variables concept page, and a Tier-1/Tier-2 roles reference (hashicorp_vault, step_ca, crowdsec, pihole, deploy_docker_compose + the gitea_* family and helpers). - opentofu/ sub-tree: factory-iac (Cloudflare/OVH/GCP/Gitea/Vault edge + cloudflare_token module), postgres-iac (per-app DB/role/pgbouncer lookup), ci-apply-flow (Gitea OIDC-JWT -> Vault -> auto-approve apply). Cross-linked bidirectionally with the lab-ecosystem guidebook and the safe-env ADR/PRD (the sandbox rehearses exactly these engines). 14 mermaid diagrams MCP-validated; zero dead links. Authored by the Lab Cartographer cohort. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.1 KiB
vibe > Guidebooks > Factory provisioning > OpenTofu > CI apply flow
CI apply flow
Note
Status: ✅ active · Last Updated: 2026-06-23 Upstream:
.gitea/workflows/iac.yaml,.gitea/workflows/postgres.yamlDownstream: factory iac, postgres iac Related: Secrets & Vault · ADR-0001 · Safe prod-like environment · QA strategy
Two Gitea Actions workflows turn every commit that touches the OpenTofu code into a live apply. IAC (.gitea/workflows/iac.yaml) drives the factory infrastructure under iac/; Postgres (.gitea/workflows/postgres.yaml) drives the database stack under postgres/iac/. They share the same two-job shape: a short OIDC-auth job feeds a Vault JWT to a tofu job that reads secrets and runs terraform apply.
Caution
auto_approve: truemeans every merge tomainapplies immediately — there is no plan-gate. Thedflook/terraform-apply@v1step skips the interactive approval, so any change that lands onmain(or any matchedpush) rewrites real cloud and homelab state without a human reviewing the plan. Mitigations are entirely upstream of CI: (1) mandatory code review on the PR before merge, and (2) least-privilege Vault policies on thegitea_cicdrole so a runaway apply can only touch the resources its token is scoped to. See ADR-0001: the sandbox lane runs the same tofu but plan-only against asandbox/state prefix and a throwaway DNS zone, so contributors can validate changes without an auto-apply.
Triggers
Both workflows fire on the same three events; only the watched path globs differ.
| Event | IAC (factory) |
Postgres |
|---|---|---|
push |
iac/*.tf, iac/*.tfvars, iac/**/*.tf, iac/**/*.tfvars |
postgres/**/*.tf, postgres/**/*.tfvars |
pull_request |
same globs (YAML anchor *tofuPaths) |
same globs (YAML anchor *postgresTofuPaths) |
workflow_dispatch |
manual, no inputs | manual, no inputs |
Important
concurrencyis keyed on${{ github.ref }}-${{ github.workflow }}withcancel-in-progress: true, so a newer push to the same branch cancels an in-flight run. Apull_requestevent triggers the workflow — but theapplystill runs, so the safety contract is "review before merge", not "CI only plans on PRs".
Job 1 — gitea_vault_auth
Mints a Gitea OIDC token that Vault will trust. The whole job is one step:
echo -n "${{ secrets.vault_oauth__sh_b64 }}" | base64 -d | bash
| Field | Value |
|---|---|
| Runner | ubuntu-latest |
| Secret consumed | vault_oauth__sh_b64 — a base64-encoded shell script |
| Step id | gitea_vault_jwt |
| Output | gitea_vault_jwt ← steps.gitea_vault_jwt.outputs.id_token |
The decoded script asks Gitea for an OIDC id_token and emits it as a step output. The tofu job declares needs: [gitea_vault_auth] so it receives needs.gitea_vault_auth.outputs.gitea_vault_jwt.
Job 2 — tofu
| Field | IAC |
Postgres |
|---|---|---|
| Job name | Tofu |
Tofu - Postgres |
needs |
gitea_vault_auth |
gitea_vault_auth |
OPENTOFU_VERSION |
1.8.2 |
1.8.2 |
TERRAFORM_VAULT_AUTH_JWT |
needs.gitea_vault_auth.outputs.gitea_vault_jwt |
same |
VAULT_CACERT |
${{ github.workspace }}/homelab.pem |
same |
| Apply path | iac |
postgres/iac |
Step order inside the job:
- read vault secret — the shared
*vault_stepanchor (see below). actions/checkout@v4— pull the repo into the workspace.- prepare vault self signed cert —
echo -n "${{ secrets.HOMELAB_CA_CERT }}" | base64 -d > $VAULT_CACERT, writing the homelab CA tohomelab.pemso the runner trustshttps://vault.arcodange.lab. - terraform apply —
dflook/terraform-apply@v1with the path above andauto_approve: true.
Vault secret reads (*vault_step)
The read vault secret step uses arcodange-org/vault-action, authenticating with method: jwt, path: gitea_jwt, role: gitea_cicd, url: https://vault.arcodange.lab, caCertificate: ${{ secrets.HOMELAB_CA_CERT }}, and jwtGiteaOIDC set to the auth job's output. The secrets it exports into the job env differ per workflow:
| Workflow | Vault path | Selector | Exported as |
|---|---|---|---|
IAC |
kvv1/google/credentials |
credentials |
GOOGLE_CREDENTIALS |
IAC |
kvv1/admin/gitea |
token |
GITEA_TOKEN |
IAC |
kvv1/admin/cloudflare |
iam_token |
CLOUDFLARE_API_TOKEN |
IAC |
kvv1/admin/ovh/app |
* (all keys) |
OVH_* |
Postgres |
kvv1/google/credentials |
credentials |
GOOGLE_BACKEND_CREDENTIALS |
Postgres |
kvv1/postgres/credentials |
* (all keys) |
TF_VAR_postgres_* |
GOOGLE_CREDENTIALS / GOOGLE_BACKEND_CREDENTIALS authenticate the GCS state backend; the TF_VAR_postgres_* fan-out feeds the Postgres module's input variables directly. See Secrets & Vault for how the gitea_cicd role and KV v1 mounts are provisioned.
End-to-end flow
%%{init: {'theme': 'base'}}%%
flowchart TD
push["push / PR / workflow_dispatch<br>on iac/** or postgres/** .tf .tfvars"] --> auth["job: gitea_vault_auth<br>base64 -d | bash -> Gitea OIDC id_token"]
auth -->|"gitea_vault_jwt output"| tofu["job: tofu<br>OPENTOFU_VERSION 1.8.2"]
tofu --> readvault["read vault secret<br>vault-action jwt role gitea_cicd"]
readvault -->|"GOOGLE_CREDENTIALS, TF_VAR_postgres_*, ..."| init["tofu init<br>GCS backend, state prefix"]
init --> apply["dflook/terraform-apply@v1<br>auto_approve: true"]
apply --> state["state updated in GCS<br>real cloud + homelab mutated"]
classDef trigger fill:#1f3a5f,stroke:#7fb0ff,color:#eaf2ff;
classDef job fill:#3a2f5f,stroke:#b39dff,color:#f3eeff;
classDef secret fill:#5f3a2f,stroke:#ffb38a,color:#fff1e8;
classDef danger fill:#5f1f2f,stroke:#ff8a9d,color:#ffe8ec;
class push trigger;
class auth,tofu,init job;
class readvault secret;
class apply,state danger;
- A push, pull_request, or workflow_dispatch event matching the
iac/**orpostgres/**path globs starts the workflow. - Job
gitea_vault_authrunsbase64 -d | bashon thevault_oauth__sh_b64secret to obtain a Gitea OIDCid_token, published as thegitea_vault_jwtoutput. - Job
tofu(gated byneeds: gitea_vault_auth) starts onubuntu-latestwithOPENTOFU_VERSION 1.8.2andTERRAFORM_VAULT_AUTH_JWTset to that output. - The read vault secret step exchanges the JWT (role
gitea_cicd, pathgitea_jwt) for the workflow's secrets and exports them as env vars (GOOGLE_CREDENTIALS/GOOGLE_BACKEND_CREDENTIALS,GITEA_TOKEN,CLOUDFLARE_API_TOKEN,OVH_*, orTF_VAR_postgres_*). tofu initconfigures the GCS backend, binding the working dir to its state prefix using the Google credentials just read.dflook/terraform-apply@v1runs againstiac(orpostgres/iac) withauto_approve: true— no plan-gate.- The state in GCS is updated and the real cloud + homelab resources are mutated to match the committed code.
Related pages
- factory iac — what the
iac/stack provisions (theIACworkflow's target). - postgres iac — the
postgres/iac/database stack (thePostgresworkflow's target). - Secrets & Vault — the
gitea_cicdrole, OIDC trust, and KV mounts behind every secret read here. - ADR-0001 · Safe prod-like environment — the sandbox lane runs the same tofu plan-only against a
sandbox/state prefix and a throwaway zone.