Skip to content

Architecture

The repositories

The platform is one shared engine and a per-customer landing-zone repository, with two supporting repositories under NRIT control.

Repository Role
PR-ops engine (nrit-tf-pr-ops, called through the public tf-pr-ops entrypoint) The shared automation: the compiled tfpr engine binary, driven by GitHub Actions reusable workflows and a composite action, that plans, applies, detects drift, and runs the gates. Customer repos call the public entrypoint at a pinned tag; the jobs check the private core out at the same tag. They do not copy it.
Landing-zone repository A per-customer Terragrunt monorepo. It holds the actual Azure landing zone under live/, four thin caller workflows that invoke the engine, and a projects.yml that tells the engine what to discover, which paths to exempt from the merge gate, and which gates to run. nrit-alz-customer-template is the template the bootstrap generates new customer repos from; nrit-alz-live is NRIT's own production repository, generated from the same template, and serves as the reference implementation.

Two supporting repositories sit behind them:

  • nrit-alz-bootstrap provisions everything a landing-zone repo needs: the state storage account (blob state and the unit-lock table, with the role assignments for both), the plan and apply OIDC identities and their federated credentials, the plan and apply environments, the customer repository generated from the template, the branch ruleset, and the self-hosted runner and its image. See Bootstrap a customer.
flowchart TB
  subgraph GH["GitHub organization"]
    LZ["Landing-zone repo<br/>(live/ + projects.yml<br/>+ four caller workflows)"]
    ENG["tf-pr-ops entrypoint + nrit-tf-pr-ops core<br/>engine at a pinned tag"]
    LZ -->|uses: @tag| ENG
    ENG -.->|tfpr binary + gate scripts<br/>at engine_ref| LZ
  end
  subgraph AZ["Customer Azure tenant"]
    RUN["Self-hosted runner<br/>(private network)"]
    STATE[("Terraform state<br/>+ unit-lock table")]
    ALZ["Landing zone<br/>(management groups,<br/>subscriptions, resources)"]
  end
  ENG -->|OIDC login| AZ
  RUN -->|plan / apply| ALZ
  RUN -->|read / write| STATE
  AVM["Public AVM registry<br/>(Terraform Registry)"]
  RUN -.->|fetch modules| AVM

How the engine is consumed

The customer repo carries four thin caller workflows, all pinned to the same engine release. Two call the engine's reusable workflows (comment commands and the drift sweep) and carry the pin twice, as the uses: ref and the engine_ref input; two call the engine's dispatch composite action (the pull request path and the unlock on close) and carry a single action pin. At run time the engine installs the prebuilt tfpr binary for that tag and checks the engine repo out into .tfpr-engine, exposed to hooks as $TFPR_ENGINE_DIR, which is where the gate scripts resolve from. So the customer repo holds only its own Terraform and the four callers; the engine is never copied in. The current pins are on Versions and upgrades; the mechanics are on The PR-ops engine.

Runner topology

Jobs run on one of two runner types, chosen per job.

  • Hosted runners (ubuntu-latest) run the jobs that only talk to the GitHub API: resolving the event, selecting projects, setting the merge gate, and reporting drift issues.
  • Self-hosted runners run every job that touches Azure: plan, apply, the drift plan, the unlock, and the drift run's lock sweep. They sit on a private network with access to the state account behind its firewall. The runner label is set with the RUNNER_LABEL variable, and jobs fall back to ubuntu-latest when it is not set (useful for demos).

The runner image, the tools it bakes in, and the sizing the bootstrap applies (and why the AVM defaults are too small for ALZ policy plans) are covered in Self-hosted runner and the engine GitHub App.

Identity and state

  • Azure access is Entra workload-identity OIDC. Two GitHub environments, plan and apply, each bind a federated credential. The plan identity is Reader (read-only) and the apply identity is Owner at the management group scope. There is no cloud secret in the repo.
  • Only the Azure jobs request a token. The trusted jobs (resolve, gate, drift's report, and the PR caller's dispatch) never request an id-token. The Azure jobs (the plan matrix, apply, unlock, drift-plan, and the lock sweep) run under the plan or apply environment and request the token there. This split keeps PR-branch code away from write credentials.
  • The OIDC subject uses immutable repository ids. GitHub renders the sub claim of newly created repos as repo:org@id/repo@id:environment:env. The bootstrap builds the federated credential subjects in that format by default (oidc_subject_uses_repo_ids), and only falls back to the legacy name-only form for repos created before that rollout.
  • State lives in an Azure storage account in the management subscription, with Entra ID auth (no account keys). On the self_hosted_private posture a firewall allows only the runner's egress address; on the GitHub-hosted posture Entra auth is the only control. Terragrunt writes each unit's state to a key derived from the unit path.

The Security model page covers the identities, the trust split, and the gates in one place.

The landing zone

The reference landing zone follows the Cloud Adoption Framework layout under live/:

  • A _foundation/ tree that is deployed first and creates the management group hierarchy, base policy, management resources (Log Analytics, monitoring), and the Azure Monitor Baseline Alerts.
  • A platform/ tree for connectivity (hub network) and other platform subscriptions.
  • A landingzones/ tree for workload subscriptions (for example a corp landing zone with its spoke network and workloads).

Every unit is plain Terraform: a main.tf sourcing a public Azure Verified Module at a pinned version, beside a thin terragrunt.hcl. The custom Azure Policy library is vendored into the repository at live/_foundation/landing-zones/lib/ and layered over the stock ALZ library. See The live tree for the full layout.

The same path, from a bootstrap-generated repo to a first applied plan, is what NRIT runs on its own tenant. nrit-alz-live is generated from the same template and calls the same engine release as a customer repo, so the platform is exercised in production before it is handed over.

Read on: How it works.