Skip to content

projects.yml schema

projects.yml at the repo root tells the engine what to discover and which hooks to run. There are three discovery modes. Terragrunt mode is exclusive; the two plain-Terraform modes can be combined.

The engine's vocabulary is project; in Terragrunt mode a project is exactly a unit, and this site uses the two words interchangeably.

Terragrunt mode

terragrunt:
  root: live               # directory that holds root.hcl
  include_dependents: true # re-plan a changed unit's downstream dependents
  exclude:                 # optional globs, relative to root
    - "**/scratch/**"
  tool: terragrunt         # optional

The shipped configuration has no excludes, on purpose: the foundation units are the highest-impact part of the tree, and excluding anything from discovery hides it from the plan, the gates, and the merge gate.

Discovery uses native Terragrunt (terragrunt find with dependencies and the DAG). A changed unit impacts that unit plus every downstream dependent, in topological order, so apply runs in the right sequence.

The gate_ignore block

gate_ignore:               # optional globs, always repo-relative
  - ".tflint.hcl"
  - ".claude/**"

Since engine v3.4.0 the merge gate fails a PR whose changed Terraform-pattern paths belong to no selected unit (see the merge gate). gate_ignore: names the paths that belong to no unit on purpose: linter configuration, bundled tooling, a shared module tree. The globs work like exclude (a pattern covers its subtree) but are always repo-relative, in both modes, because the paths they cover live outside the units by definition. Keep the list short: every entry is a place where Terraform can change without the gate noticing.

Explicit projects (plain Terraform)

projects:
  - name: prod
    dir: environments/prod
    tool: terraform

The label is <dir>/<name>.

Generated projects (plain Terraform)

generate_projects:
  include:
    - "environments/**"
  exclude:
    - "**/modules/**"
  traverse_to_nested_projects: true   # default true

Every folder with *.tf under an include glob (minus excludes and shared modules) becomes a project, labeled by its path. Globs support **.

By default the walk keeps descending inside a project, so a nested folder with its own *.tf files becomes a second project. Set traverse_to_nested_projects: false to stop at the first project on each branch, so a modules/ folder inside a root never becomes a project of its own without needing an exclude glob.

The drift block

drift:
  enabled: true    # default true
  include:
    - "live/**"
  exclude:
    - "sandbox/**"

Scopes or disables the daily drift sweep. With no drift: block, every discovered unit is swept.

The steps block

steps:
  post_plan:
    - bash "$TFPR_ENGINE_DIR/scripts/conftest-gate.sh"
    - CHECKOV_SOFT_FAIL=1 bash "$TFPR_ENGINE_DIR/scripts/checkov-gate.sh"
    - bash "$TFPR_ENGINE_DIR/scripts/infracost-gate.sh"

This flat form is the plan stage only (engine v6.0.0): the hooks run on every plan and every drift plan, and not on apply, which re-takes a plan the guard has already matched to the reviewed one. To run hooks at apply time, nest the stages:

steps:
  plan:
    post_plan:
      - bash "$TFPR_ENGINE_DIR/scripts/conftest-gate.sh"
  apply:
    post_apply:
      - ./scripts/notify.sh

A flat pre_apply or post_apply, or a misspelt slot, fails discovery with the fix named.

$TFPR_ENGINE_DIR points at the engine checkout the reusable workflow provides. It defaults to the workspace, so the same paths resolve in vendored mode.

Hooks run at fixed seams around init, plan, and apply. A global steps: applies to every unit. An individual project may set its own steps:, which fully replaces the global one (there is no merge). See the hook and step model.

One parser, loud failures

Since engine v1.12.0, discovery is a compiled part of the engine with a single parser for this file. A malformed or contradictory config fails the run with a named error rather than being silently ignored, and a terragrunt: block that supersedes projects: or generate_projects: says so in the run log. No Python or PyYAML is involved.