[vibe](../../../README.md) > [Guidebooks](../../README.md) > [Factory provisioning](../README.md) > [OpenTofu](README.md) > **CI apply flow** # CI apply flow > [!NOTE] > **Status:** ✅ active · **Last Updated:** 2026-06-23 > **Upstream:** [`.gitea/workflows/iac.yaml`](../../../../.gitea/workflows/iac.yaml), [`.gitea/workflows/postgres.yaml`](../../../../.gitea/workflows/postgres.yaml) > **Downstream:** [factory iac](factory-iac.md), [postgres iac](postgres-iac.md) > **Related:** [Secrets & Vault](../../lab-ecosystem/secrets-and-vault.md) · [ADR-0001 · Safe prod-like environment](../../../ADR/0001-safe-prod-like-environment.md) · [QA strategy](../../../PRD/safe-prod-like-environment/qa-strategy.md) Two Gitea Actions workflows turn every commit that touches the OpenTofu code into a live `apply`. `IAC` ([`.gitea/workflows/iac.yaml`](../../../../.gitea/workflows/iac.yaml)) drives the factory infrastructure under [`iac/`](../../../../iac/); `Postgres` ([`.gitea/workflows/postgres.yaml`](../../../../.gitea/workflows/postgres.yaml)) drives the database stack under [`postgres/iac/`](../../../../postgres/). 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: true` means every merge to `main` applies immediately — there is no plan-gate.** The `dflook/terraform-apply@v1` step skips the interactive approval, so any change that lands on `main` (or any matched `push`) 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 the `gitea_cicd` role so a runaway apply can only touch the resources its token is scoped to. See [ADR-0001](../../../ADR/0001-safe-prod-like-environment.md): the sandbox lane runs the *same* tofu but **plan-only** against a `sandbox/` 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] > `concurrency` is keyed on `${{ github.ref }}-${{ github.workflow }}` with `cancel-in-progress: true`, so a newer push to the same branch cancels an in-flight run. A `pull_request` event triggers the workflow — but the `apply` still 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: ```bash 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: 1. **read vault secret** — the shared `*vault_step` anchor (see below). 2. **`actions/checkout@v4`** — pull the repo into the workspace. 3. **prepare vault self signed cert** — `echo -n "${{ secrets.HOMELAB_CA_CERT }}" | base64 -d > $VAULT_CACERT`, writing the homelab CA to `homelab.pem` so the runner trusts `https://vault.arcodange.lab`. 4. **terraform apply** — `dflook/terraform-apply@v1` with the path above and `auto_approve: true`. ### Vault secret reads (`*vault_step`) The `read vault secret` step uses [`arcodange-org/vault-action`](https://gitea.arcodange.lab/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](../../lab-ecosystem/secrets-and-vault.md) for how the `gitea_cicd` role and KV v1 mounts are provisioned. ## End-to-end flow ```mermaid %%{init: {'theme': 'base'}}%% flowchart TD push["push / PR / workflow_dispatch
on iac/** or postgres/** .tf .tfvars"] --> auth["job: gitea_vault_auth
base64 -d | bash -> Gitea OIDC id_token"] auth -->|"gitea_vault_jwt output"| tofu["job: tofu
OPENTOFU_VERSION 1.8.2"] tofu --> readvault["read vault secret
vault-action jwt role gitea_cicd"] readvault -->|"GOOGLE_CREDENTIALS, TF_VAR_postgres_*, ..."| init["tofu init
GCS backend, state prefix"] init --> apply["dflook/terraform-apply@v1
auto_approve: true"] apply --> state["state updated in GCS
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; ``` 1. A **push**, **pull_request**, or **workflow_dispatch** event matching the `iac/**` or `postgres/**` path globs starts the workflow. 2. Job **`gitea_vault_auth`** runs `base64 -d | bash` on the `vault_oauth__sh_b64` secret to obtain a Gitea OIDC `id_token`, published as the `gitea_vault_jwt` output. 3. Job **`tofu`** (gated by `needs: gitea_vault_auth`) starts on `ubuntu-latest` with `OPENTOFU_VERSION 1.8.2` and `TERRAFORM_VAULT_AUTH_JWT` set to that output. 4. The **read vault secret** step exchanges the JWT (role `gitea_cicd`, path `gitea_jwt`) for the workflow's secrets and exports them as env vars (`GOOGLE_CREDENTIALS` / `GOOGLE_BACKEND_CREDENTIALS`, `GITEA_TOKEN`, `CLOUDFLARE_API_TOKEN`, `OVH_*`, or `TF_VAR_postgres_*`). 5. **`tofu init`** configures the GCS backend, binding the working dir to its state prefix using the Google credentials just read. 6. **`dflook/terraform-apply@v1`** runs against `iac` (or `postgres/iac`) with `auto_approve: true` — no plan-gate. 7. The **state** in GCS is updated and the real cloud + homelab resources are mutated to match the committed code. ## Related pages - [factory iac](factory-iac.md) — what the `iac/` stack provisions (the `IAC` workflow's target). - [postgres iac](postgres-iac.md) — the `postgres/iac/` database stack (the `Postgres` workflow's target). - [Secrets & Vault](../../lab-ecosystem/secrets-and-vault.md) — the `gitea_cicd` role, OIDC trust, and KV mounts behind every secret read here. - [ADR-0001 · Safe prod-like environment](../../../ADR/0001-safe-prod-like-environment.md) — the sandbox lane runs the same tofu plan-only against a `sandbox/` state prefix and a throwaway zone.