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>
7.8 KiB
vibe > Guidebooks > Factory provisioning > OpenTofu > postgres iac
postgres iac — the postgres/iac/ state root
Note
Status: ✅ active · Last Updated: 2026-06-23 Code:
postgres/iac/· State backend:gs://arcodange-tf/factory/postgres(postgres/iac/backend.tf) Upstream: OpenTofu hub · Factory provisioning hub · Lab ecosystem · 01 factory Related: Naming conventions · Secrets & Vault · CI apply flow · factory iac · ADR-0001 safe prod-like environment
The postgres/iac/ root provisions PostgreSQL roles, databases, and the pgbouncer auth function on the live cluster database — one strand of the per-application <app> join key described in Naming conventions. For each application it creates a non-login owner role, an <app> database owned by that role, and a user_lookup() function that lets PgBouncer authenticate against pg_shadow. A single credentials_editor login role (whose password is stored in Vault) is granted admin over every per-app role so that downstream tooling can mint application credentials without superuser rights.
This root's state lives at gs://arcodange-tf/factory/postgres and is applied by .gitea/workflows/postgres.yaml on any change under postgres/** — see CI apply flow.
Caution
This root runs as a PostgreSQL superuser (
postgres/iac/providers.tf:superuser = true) pinned to the live database at192.168.1.202(pi2) through PgBouncer, withsslmode = disable. The provider can therefore drop or alter live application databases — an errantterraform destroyor a renamedapplicationsentry will delete real data. And because the only route to Postgres is via PgBouncer on that host, if PgBouncer is down OpenTofu cannot connect and no apply can run. Treat everypostgres/**merge as a production database change (ADR-0001).
Providers
Declared in postgres/iac/providers.tf.
| Provider | Source | Version | Connection | Auth |
|---|---|---|---|---|
postgresql |
cyrilgdn/postgresql |
1.24.0 |
host 192.168.1.202 (pi2), via PgBouncer, sslmode = disable, superuser = true |
var.POSTGRES_USERNAME / var.POSTGRES_PASSWORD (TF vars from TF_VAR_POSTGRES_*, sourced from Vault in CI) |
vault |
vault |
4.4.0 |
https://vault.arcodange.lab |
JWT login — mount gitea_jwt, role gitea_cicd |
The two POSTGRES_* variables are declared sensitive in the same file; CI populates them from Vault as TF_VAR_POSTGRES_USERNAME / TF_VAR_POSTGRES_PASSWORD (see CI apply flow).
The application set
Everything in this root fans out over one variable. var.applications is a set(string) (variables.tf) whose members are listed in terraform.tfvars:
applications member |
|---|
webapp |
erp |
crowdsec |
plausible |
dance-lessons-coach |
Adding an app to that list creates a full role + database + lookup-function bundle on the next apply; removing one would DROP the live database (see the caution above).
The credentials_editor role
Defined in postgres/iac/main.tf. A single login role, granted admin over every per-app role, whose credentials downstream tooling uses to provision application logins.
| Resource | Type | Detail |
|---|---|---|
random_password.credentials_editor |
password | length 24, override_special = "-:!+<>" |
postgresql_role.credentials_editor |
role | login = true, create_role = true; lifecycle { ignore_changes = [roles] } so its grant membership isn't reverted |
vault_kv_secret.postgres_admin_credentials |
Vault KVv1 secret | kvv1/postgres/credentials_editor/credentials — username + password |
Per-application resources
For each member of var.applications, main.tf creates the following (all for_each over the set):
| Resource | Type | What it creates |
|---|---|---|
postgresql_role.app_role["<app>"] |
role | non-login role <app>_role (login = false) — owns the database |
postgresql_grant_role.credentials_editor_app_role["<app>"] |
grant | credentials_editor → <app>_role WITH ADMIN OPTION |
postgresql_database.app_db["<app>"] |
database | database <app>, owner <app>_role, template = template0, alter_object_ownership = true |
postgresql_function.pgbouncer_user_lookup["<app>"] |
function | user_lookup(i_username text) in db <app> — see below |
postgresql_grant.pgbouncer_user_lookup_public_revoke["<app>"] |
grant | revoke (empty privileges) of user_lookup from role public in schema public |
postgresql_grant.pgbouncer_user_lookup["<app>"] |
grant | EXECUTE on user_lookup to role pgbouncer_auth; depends_on the public-revoke (the two grants can't run in parallel) |
So webapp yields role webapp_role, database webapp, function webapp.user_lookup, and the matching grants; likewise for erp, crowdsec, plausible, and dance-lessons-coach.
The pgbouncer user_lookup() function
postgresql_function.pgbouncer_user_lookup defines a plpgsql function with security_definer = true and parallel = "SAFE". It takes i_username (IN, text) and returns a record of uname + phash:
BEGIN
SELECT usename, passwd FROM pg_catalog.pg_shadow
WHERE usename = i_username INTO uname, phash;
RETURN;
END;
PgBouncer's auth_query calls this to fetch the stored password hash. Because reading pg_shadow is privileged, the function is SECURITY DEFINER (runs as its owner). Access is locked down in two steps: first revoke the default public execute grant, then grant EXECUTE only to the pgbouncer_auth role — the pgbouncer_auth role itself is expected to already exist on the server (it is not created by this root).
Note
The two grants are ordered with an explicit
depends_on:postgresql_grant.pgbouncer_user_lookupwaits forpostgresql_grant.pgbouncer_user_lookup_public_revokebecause the provider can't apply both grants on the same object concurrently.
Vault layout
This root writes a single KVv1 secret.
| Path | Engine | Contents |
|---|---|---|
kvv1/postgres/credentials_editor/credentials |
KVv1 (vault_kv_secret) |
username, password of the credentials_editor login role |
No outputs
There is no outputs.tf in this root. Nothing is exported as a Terraform output — the credentials_editor credentials are delivered into Vault, and the per-app roles/databases/functions are side effects on the live server. Consumers read the credentials from kvv1/postgres/credentials_editor/credentials, not from state outputs.
See also
- Naming conventions — the
<app>databases here are one strand of the per-application<app>join key (alongside namespaces, Vault paths, and repos). - CI apply flow — how
postgres/**changes reachgs://arcodange-tf/factory/postgresand whereTF_VAR_POSTGRES_*come from. - factory iac — the sibling root for everything outside the cluster.
- Secrets & Vault.