Files
factory/vibe/guidebooks/factory-provisioning/opentofu/postgres-iac.md
Gabriel Radureau dbe32161dc docs(vibe): add factory-provisioning guidebook (Ansible + OpenTofu)
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>
2026-06-23 21:11:51 +02:00

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 at 192.168.1.202 (pi2) through PgBouncer, with sslmode = disable. The provider can therefore drop or alter live application databases — an errant terraform destroy or a renamed applications entry 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 every postgres/** 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/credentialsusername + 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_lookup waits for postgresql_grant.pgbouncer_user_lookup_public_revoke because 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 reach gs://arcodange-tf/factory/postgres and where TF_VAR_POSTGRES_* come from.
  • factory iac — the sibling root for everything outside the cluster.
  • Secrets & Vault.