Skip to content

Running yodel alongside Terraform

llms.txtlists every page for an agent

Terraform and yodel each own one layer. Terraform creates the server, the database or schema, and the users yodel connects as. yodel owns everything inside: the tables, views and functions, their migrations, and the history of what was applied. The pipeline runs terraform apply first and yodel apply <env> after it.

For each environment, Terraform creates:

  • the database (ClickHouse) or schema (Postgres) the project declares, and the one yodel keeps its history in,
  • a reader user, which can read and nothing else,
  • a writer user, which can change the schema and write the history,
  • the secrets holding both users’ passwords, where the pipeline reads them.

yodel refuses an environment whose reader and writer are the same user (Credentials: a reader and a writer).

On Postgres, with the postgresql provider:

resource "random_password" "reader" { length = 32 }
resource "random_password" "writer" { length = 32 }
resource "postgresql_role" "writer" {
name = "app_writer"
login = true
password = random_password.writer.result
}
resource "postgresql_role" "reader" {
name = "app_reader"
login = true
password = random_password.reader.result
}
# The writer owns the declared schema and the history schema.
resource "postgresql_schema" "app" {
name = "app"
owner = postgresql_role.writer.name
}
resource "postgresql_schema" "history" {
name = "yodeler"
owner = postgresql_role.writer.name
}
resource "postgresql_grant" "reader_usage" {
database = "postgres"
role = postgresql_role.reader.name
schema = postgresql_schema.app.name
object_type = "schema"
privileges = ["USAGE"]
}
resource "postgresql_default_privileges" "reader_select" {
database = "postgres"
role = postgresql_role.reader.name
owner = postgresql_role.writer.name
schema = postgresql_schema.app.name
object_type = "table"
privileges = ["SELECT"]
}

The reader needs the same USAGE and default SELECT on the history schema, so yodel status and yodel drift can read it.

On ClickHouse, the users are created with SQL by whatever provisions the server, once per environment:

CREATE USER app_writer IDENTIFIED WITH sha256_password BY '<writer password>';
CREATE USER app_reader IDENTIFIED WITH sha256_password BY '<reader password>' SETTINGS readonly = 1;
CREATE DATABASE events;
CREATE DATABASE yodeler;
GRANT ALL ON events.* TO app_writer;
GRANT ALL ON yodeler.* TO app_writer;
GRANT SELECT, SHOW ON events.* TO app_reader;
GRANT SELECT, SHOW ON yodeler.* TO app_reader;

Terraform then writes each password into the secret store the pipeline reads: the forge’s secrets, or a cloud secret manager. The variable names yodel reads are <ENV>_<DIALECT>_READER_USER, <ENV>_<DIALECT>_READER_PASSWORD, <ENV>_<DIALECT>_WRITER_USER and <ENV>_<DIALECT>_WRITER_PASSWORD, for example PROD_POSTGRES_WRITER_PASSWORD. Setting up each forge says where each forge keeps them.

The infrastructure job runs first, and the apply wave yodel ci renders runs after it:

  1. terraform apply creates or updates the server, the database or schema, and the users.
  2. yodel apply <env> applies the pending migrations, behind the approval bound to the plan digest (Approval).

On a pull request, terraform plan and yodel plan each post their own plan. A reviewer approves the schema change with yodel approve <env>; the infrastructure change follows your Terraform review as before.

  • Terraform state holds the server, the database or schema, the users and their grants.
  • yodel’s history table holds every migration applied to each environment, with its checksum, the plan digest it was approved under, who applied it, and each statement’s outcome. A failed apply resumes from it.

Neither tool reads the other’s state. Terraform never declares the tables, so a schema change never shows as Terraform drift, and yodel drift reports only the objects src/ declares.

A schema change waits for a person’s approval of its exact plan, and resumes from yodel’s history when it stops part way. Inside terraform apply it would either hold Terraform’s run open for that approval or apply without it, and Terraform state would become a second record of the schema. Keeping yodel apply as its own step keeps one record and one approval.

The same holds when the cluster runs the deploy: Applying from Kubernetes runs yodel apply <env> as a Job after the infrastructure is in place.

The scenario claims below run what this page describes against a real server, once plain (it passes) and once with the behaviour broken (the claim catches it). Claims status lists every claim.

Claim What it says Plain, broken Last run
approval a pending migration applies only after chant approve of its plan digest, and the history records that digest; a failing pre-migration check refuses it, and so does a policy rule, read at the base commit, unless an override is recorded for that digest, for a yodel revert as for an apply; the audit log derived from the history and the ledger accounts for every approval and apply, and names one removed; a reader and a writer that are one user are refused, and the reader, its password a minted token, cannot write; the pull request comment lists the pending migrations with each statement’s class, and the digest the gate asks for ClickHouse: pass, caught; Postgres: pass, caught bb8cccc, 2026-10-12

SQL Yodeler