Skip to content

Bootstrap reference

Everything nrit-alz-bootstrap creates, every input it takes, and what it deliberately leaves to you. For the procedure, see Bootstrap a customer.

What it creates

State backend

Terraform state for the customer's landing zone, in the management subscription (state.tf).

Resource Detail
Resource group rg-cicd-state-<location_short>
Storage account stcicd<location_short><random4>, Standard ZRS, StorageV2, TLS 1.2 minimum
Auth Entra ID only. shared_access_key_enabled = false, no public blob access
Data protection Blob versioning on, seven-day blob and container soft delete
Container tfstate, created through the ARM control plane with azapi because the data plane needs a shared key
Role assignments Storage Blob Data Contributor on the container for both deploy identities, plus Storage Table Data Contributor at account scope for the cross-PR unit-lock table (the engine creates the table on first use, so no narrower scope exists at assignment time)

One account holds the state for every unit in the customer repository. Units are isolated by blob key (path_relative_to_include() in root.hcl), not by separate accounts. See The root contract.

Identities and OIDC

Two user-assigned managed identities and their federated credentials (identity.tf), in rg-cicd-identity-<location_short>.

Identity Name Management group role GitHub environment
Plan id-cicd-plan-<location_short> plan_role_definition_name, default Reader plan
Apply id-cicd-apply-<location_short> apply_role_definition_name, default Owner apply

Apply needs Owner because the foundation creates role assignments and policy.

The federated credential subject binds two claims, the customer repository and the deployment environment, for example repo:nrit-solutions/nrit-alz-live:environment:plan. The trust boundary is the repository plus the GitHub environment. The environments carry no protection rules of their own.

There is no separate drift identity. The scheduled drift workflow runs in the plan environment on the plan identity, so the plan credential covers it and drift reads the tenant with the same least-privilege identity as the pull-request plan. See Drift detection.

Why the engine tag is not in the subject

The reusable-pipeline tag and workflow file are deliberately absent from the subject, so bumping the engine version needs no re-bootstrap. This was a trade, not an oversight: Entra's flexible federated identity credential (preview) caps the claim-matching expression at 128 characters, which a full repository plus environment plus job_workflow_ref plus tag subject exceeds. Keeping the workflow-file pin would have meant dropping the repository or the environment claim. The Owner path is gated instead by the engine's review-decision check, backed by the approved-PR ruleset.

GitHub

In the organization named by github_organization (github.tf).

Thing Detail
Customer repository Private, generated from nrit-alz-customer-template. Issues enabled, because drift reports to Issues and template repositories can inherit issues off. The template block is ignored after creation
OIDC subject customization include_claim_keys = ["repository", "environment"], matching the federated credentials
Environments plan and apply, no protection rules
Approver team <customer_name>-alz-apply-approvers, members from apply_approvers, push permission on the repository. Created only when the list is non-empty
Ruleset require-approved-pr-to-main, active on the default branch. Requires an approved pull request and any required_status_checks, each pinned to the checks App when checks_app_id is set. Stale reviews dismissed on push. Organization admins can bypass, for break-glass
Checks App installation The repository is added to the checks App's "selected repositories" installation when checks_app_installation_id is set
Repository variables AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, BACKEND_AZURE_RESOURCE_GROUP_NAME, BACKEND_AZURE_STORAGE_ACCOUNT_NAME, BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_NAME, RUNNER_LABEL, TFPR_EXTRA_TOOLS, ENGINE_APP_CLIENT_ID when engine_app_client_id is set, and TFPR_CHECKS_APP_CLIENT_ID when checks_app_client_id is set
Repository secrets ENGINE_APP_PRIVATE_KEY when engine_app_private_key is set, TFPR_CHECKS_APP_PRIVATE_KEY when checks_app_private_key is set
Environment variables AZURE_CLIENT_ID per environment, pointing at that environment's identity

AZURE_CLIENT_ID is an environment variable rather than a repository variable on purpose. A single repository-level value would hand the plan jobs the Owner identity, or the apply jobs the Reader one.

The ruleset gates the merge, not the apply. There is no push trigger and nothing applies on merge. The PR-ops engine (nrit-tf-pr-ops) applies from an /apply comment on the open pull request and checks the same review decision itself. A ruleset is used rather than environment required-reviewers because GitHub does not offer environment protection on private repositories on the Team plan, whereas rulesets work there. That keeps the gate portable.

Runner infrastructure

Created only when network_posture = "self_hosted_private" (runners.tf, state-private-endpoint.tf), in rg-cicd-runners-<location_short>.

Thing Detail
Runner module Azure/avm-ptn-cicd-agents-and-runners/azurerm version 0.6.0, repo-scoped GitHub runners. Registration and the KEDA scaler authenticate as the runners GitHub App (short-lived installation tokens); a PAT is the fallback
Network A standalone bootstrap virtual network from runner_vnet_address_space, single zone. Independent of the landing zone connectivity stack, so there is no circular dependency at onboarding. It can be peered to the hub later
Egress With nat_gateway_enabled (the default): a public IP the bootstrap owns, attached to the module's NAT gateway, the single address allowlisted on the state firewall. With it off, egress uses the Container Apps managed path with a dynamic IP and no address is allowlisted
State access Private endpoints for the state account's blob and table services (the table endpoint serves the unit-lock table), plus privatelink.blob.core.windows.net and privatelink.table.core.windows.net private DNS zones linked to the runner network
Compute Container App Jobs by default, scaling to zero when idle. Container Instances as the always-on alternative
Registry A bootstrap-owned ACR (Premium, public access disabled) holding the prebuilt signed runner image, imported by digest and pulled over its own private endpoint. On the build-from-source fallback it is the runner module's ACR with a build task instead

The private endpoint is the real path to state, not the IP allowlist. Azure Container Instances do not reliably egress through a subnet NAT gateway, so state traffic goes over the virtual network and never touches the public endpoint.

Engine self-test (platform owner only)

Created only when engine_selftest_repository is set (engine-selftest.tf, modules/engine-selftest/), in rg-cicd-selftest-<location_short>. It gives the engine repository a plan-only identity, so the engine can plan its own examples/prod on every pull request and in its drift sweep, on GitHub-hosted runners. The default creates nothing, so a customer tfvars never carries it.

Thing Detail
Resource group rg-cicd-selftest-<location_short>. The identity holds Reader here and nothing else
Identity id-cicd-selftest-plan-<location_short>, federated to repo:<github_organization>/<engine_selftest_repository>:environment:plan, GitHub's default subject for an environment-bound job. The engine repository keeps the default OIDC template
State account stcicdselftest<location_short><random4>, Standard LRS, StorageV2, TLS 1.2 minimum, Entra ID only, blob versioning and seven-day soft delete. Public endpoint, because hosted runners cannot reach the private state account; authentication is the control, not the network
Container tfstate, created through the ARM control plane with azapi
Role assignments Reader on the resource group, Storage Blob Data Contributor on the container, and Storage Table Data Contributor at account scope for the unit-lock table. The operator named by engine_selftest_operator_object_id gets Storage Blob Data Contributor on the container for the one-time examples/prod baseline apply
GitHub On the engine repository: the plan environment (adopted when it already exists) with AZURE_CLIENT_ID, and the repository variables AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, TFPR_INIT_ARGS (the three -backend-config pairs) and TFPR_LOCK_STORAGE_ACCOUNT

The identity is plan-only. /apply on the engine repository fails at Azure login by design, and the customer's state account, identities, runners, and repository are untouched.

Network postures

One variable, network_posture, selects the model, and it sets the pattern every later IaC deployment for the customer follows.

github_hosted (default) self_hosted_private
Runner GitHub-hosted, ubuntu-latest Self-hosted on the bootstrap network, one concurrent runner by default
RUNNER_LABEL ubuntu-latest self-hosted
State account network rules None. Public endpoint, Entra ID auth Default-deny, allowing the runner egress IP (and the operator IP when allow_state_access_from_my_ip is true). Reached over a private endpoint
TFPR_EXTRA_TOOLS conftest checkov infracost infracost
Use for Non-production, NRIT dogfood, UNO demos Customer production

self_hosted_private is the production standard. A production landing zone's state should be reachable only from the runner, and later workloads sit behind private endpoints that only an in-network runner can reach. github_hosted is the simple, low-cost path for everything else.

TFPR_EXTRA_TOOLS differs because the custom self-hosted runner image already carries conftest and checkov, so only infracost is installed per run. GitHub-hosted runners carry none of them.

Variables

Required

No default. Set every one of these in customers/<customer>.tfvars.

Variable Type Purpose
customer_name string Customer slug used in resource names. Two to twenty characters, lower case letters, digits, hyphens
tenant_id string The customer's Entra ID tenant
management_subscription_id string Holds the state, the CI/CD identities, and the runners. There is no separate bootstrap subscription
customer_repository_name string Name of the infrastructure-live repository to generate

Deploy identity scope

Variable Default Purpose
management_group_scope_id null Management group the plan and apply identities operate at, the ALZ parent. Null means the tenant root group. For an apply test, point this at a throwaway parent management group, never the live ALZ tree
plan_role_definition_name "Reader" Built-in role for the plan identity at that scope
apply_role_definition_name "Owner" Built-in role for the apply identity at that scope. Owner is required because the foundation creates role assignments and policy

GitHub

Variable Default Purpose
github_organization "nrit-solutions" Organization that owns the customer repository
template_repository {owner = "nrit-solutions", repository = "nrit-alz-customer-template"} Template the customer repository is generated from
default_branch "main" Default branch of the customer repository
oidc_subject_uses_repo_ids true Build federated credential subjects with immutable repository ids. See the note below
require_approved_pr true Create the require-approved-pr-to-main ruleset. False skips the merge gate entirely
required_approving_review_count 1 Approving reviews the ruleset requires. Only applies when require_approved_pr is true
required_status_checks [] Status-check contexts the ruleset requires green before merge. Only applies when require_approved_pr is true
apply_approvers [] GitHub usernames that review the customer repository's pull requests. A team is created, populated, and given push permission
engine_app_client_id "" Client id of the engine App, set as the ENGINE_APP_CLIENT_ID repository variable. Empty skips it
checks_app_client_id "" Client id of the checks App, set as the TFPR_CHECKS_APP_CLIENT_ID repository variable. Empty skips it
checks_app_installation_id null Installation id of the checks App on the customer organization. When set, the repository is added to that installation, which must use "Only select repositories". Needs checks_app_client_id
checks_app_id null Numeric App ID of the checks App, not the Iv... client id. When set, every required_status_checks context is pinned to the App in the ruleset, so only the engine's own check run can satisfy the merge gate. Needs checks_app_client_id
github_runners_app_id null Numeric App ID of the runners GitHub App (repository permissions: Administration read and write, Actions read; installed on the customer repository). When set, runner registration and the KEDA scaler authenticate as the App with short-lived installation tokens; null keeps the PAT path. Use the numeric App ID, not the Iv... client id
github_runners_app_installation_id null Installation id of the runners App on the customer organization. Set together with github_runners_app_id

required_status_checks defaults to no check at all

The recommended value is ["tf-pr-ops / merge-gate"], already present in customers/_example.tfvars. That gives the apply-before-merge model: a pull request cannot merge until /apply has run, and the engine seeds the check success when nothing is affected. The default [] means no status check is required, so a pull request can merge with unapplied changes.

required_approving_review_count = 0 needs a manual follow-up

A single-writer organization cannot self-approve on GitHub, so it sets 0 to keep the pull-request gate without an unmeetable review. GitHub then returns an empty reviewDecision, and the engine refuses to apply on an empty decision. Such a repository also needs the repository variable TFPR_ALLOW_UNREVIEWED_APPLY set to true by hand. The bootstrap does not manage that variable, so a re-run will not create or restore it. With the default of 1 the decision is never empty and no follow-up is needed. See Secrets and variables.

The OIDC subject format

GitHub presents the sub claim of newly created repositories with immutable ids, repo:org@<org id>/repo@<repo id>:environment:<env>. oidc_subject_uses_repo_ids defaults to true and builds the federated credential subjects in that format. Set it to false only for a customer repository created before GitHub's immutable-reference rollout, which still presents the legacy repo:org/repo:environment:<env> form.

Engine self-test

Platform owner only. Every customer tfvars leaves both at their defaults.

Variable Default Purpose
engine_selftest_repository null Name of the engine repository in github_organization (nrit-tf-pr-ops for NRIT) that gets the plan-only self-test identity: a resource group it can only read, a small public Entra-only state account for examples/prod and the lock table, a federated credential for the repository's plan environment, and the AZURE_*, TFPR_INIT_ARGS, and TFPR_LOCK_STORAGE_ACCOUNT Action variables. Null creates nothing
engine_selftest_operator_object_id null Entra object id of the operator who applies the examples/prod baseline once from a laptop, because the self-test identity cannot apply. Gets Storage Blob Data Contributor on the self-test state container; Owner on the subscription does not cover the blob data plane. Look it up with az ad signed-in-user show --query id -o tsv. Null skips the grant. Needs engine_selftest_repository

Runners and network

Variable Default Purpose
network_posture "github_hosted" github_hosted or self_hosted_private. self_hosted_private is the production standard
allow_state_access_from_my_ip false On self_hosted_private, also allow the operator's public IP through the state account firewall, for the initial apply. Leave false in steady state
runner_compute_type "azure_container_app" azure_container_app (Container App Jobs, scale to zero) or azure_container_instance (always-on, no cold start, standing cost)
container_app_max_execution_count 1 Maximum concurrent Container App Job executions, one ephemeral runner each. One to ten. Raising it is close to cost-neutral because the job still scales to zero
container_instance_count 1 Always-on container instance runners. One to ten. Each is a direct standing cost
container_app_container_cpu 2 vCPU per runner replica. The AVM default of 1 starves the heavy ALZ policy-library plans
container_app_container_memory "4Gi" Memory per replica. The AVM default of 2Gi OOM-kills provider subprocesses on the heaviest units (amba runs two ALZ provider instances), leaving Terraform hanging on the plugin RPC. Container Apps requires 2Gi per vCPU
container_app_replica_timeout 7200 Seconds a replica may live before the job kills it, which also kills the workflow job. The AVM default of 1800 is too short for the slow ALZ policy-library plans
container_app_polling_interval_seconds 10 Seconds between KEDA checks of the GitHub job queue. A queued job waits about half this interval before its runner starts, on top of the container start. The AVM default of 30 added about 15 seconds to every job. Not billed by Azure; each check is one or two GitHub API requests on the runners App
runner_vnet_address_space "10.0.0.0/24" Address space for the standalone bootstrap network. The runner module carves its runner, registry, and endpoint subnets from this
nat_gateway_enabled true Attach the module's NAT gateway and the bootstrap-owned public IP, giving the runners a fixed egress address for the state firewall. With it off, egress uses the Container Apps managed path with a dynamic IP. Read the bootstrap README's known issue before disabling it on an existing deployment
runner_image_repository "ghcr.io/nrit-solutions/alz-runner" Public registry path of the prebuilt signed runner image, without tag or digest
runner_image_version current release Version (tag) of the prebuilt runner image, recorded as the target tag in the in-tenant registry so an operator can read which version a customer runs. Pin it per customer, from the same release as the digest
runner_image_digest current release Manifest digest of the prebuilt runner image. The digest, not the tag, is what gets imported and what the job pulls. A release bump changes version and digest together
runner_image_commit null Build-from-source fallback: the nrit-alz-bootstrap git ref holding runner-image/github-runner-aca. When set, an in-tenant ACR build task builds the image from that ref and the three pins above are ignored. Use a commit SHA so the tag changes when the Dockerfile does

Naming and tagging

Variable Default Purpose
location "westeurope" Azure region for the bootstrap resources
location_short "weu" Short region code used in resource names
tags {} Tags applied to all bootstrap resources

Secrets

Never in a tfvars file. All of them are Terraform variables supplied through the environment.

Environment variable Required Purpose
GITHUB_TOKEN or TF_VAR_github_token yes PAT with repo and admin:org scope in the organization that receives the customer repository. Authenticates the github provider
TF_VAR_github_runners_app_private_key self_hosted_private with the runners App Private key (PEM) of the runners GitHub App. Required when github_runners_app_id is set
TF_VAR_github_runners_token self_hosted_private PAT fallback PAT that registers the self-hosted runners when the runners App is not configured, with repo or org admin scope. Falls back to github_token. PATs expire; an expired one silently stops runner scale-up
TF_VAR_engine_app_private_key when engine_app_client_id is set Engine App private key, PEM. Set as the ENGINE_APP_PRIVATE_KEY repository secret. Omitting it destroys any existing secret
TF_VAR_checks_app_private_key when checks_app_client_id is set Checks App private key, PEM. Set as the TFPR_CHECKS_APP_PRIVATE_KEY repository secret. Omitting it destroys any existing secret
TF_VAR_runner_image_context_token build-from-source only PAT the ACR build task uses to clone the context repo when runner_image_commit is set. Falls back to github_token. The default prebuilt-image import needs no token

GITHUB_TOKEN is not always enough

GITHUB_TOKEN only authenticates the provider. The build-from-source runner image needs the token as a real Terraform value, because it is passed into the ACR build task, so export TF_VAR_github_token or TF_VAR_runner_image_context_token for that path. The default prebuilt-image import does not have this requirement.

Variable ownership

Which values the customer repository depends on, and who puts them there. The full list of what each one does is in Secrets and variables.

Name Kind Owner
AZURE_CLIENT_ID Environment variable Bootstrap, one per environment
AZURE_TENANT_ID Repository variable Bootstrap
AZURE_SUBSCRIPTION_ID Repository variable Bootstrap
BACKEND_AZURE_RESOURCE_GROUP_NAME Repository variable Bootstrap
BACKEND_AZURE_STORAGE_ACCOUNT_NAME Repository variable Bootstrap
BACKEND_AZURE_STORAGE_ACCOUNT_CONTAINER_NAME Repository variable Bootstrap
RUNNER_LABEL Repository variable Bootstrap, derived from network_posture
TFPR_EXTRA_TOOLS Repository variable Bootstrap, derived from network_posture
ENGINE_APP_CLIENT_ID Repository variable Bootstrap, when engine_app_client_id is supplied
ENGINE_APP_PRIVATE_KEY Secret Bootstrap, when engine_app_private_key is supplied
TFPR_CHECKS_APP_CLIENT_ID Repository variable Bootstrap, when checks_app_client_id is supplied
TFPR_CHECKS_APP_PRIVATE_KEY Secret Bootstrap, when checks_app_private_key is supplied
INFRACOST_API_KEY Secret You. The bootstrap never sets it
TFPR_ALLOW_UNREVIEWED_APPLY Repository variable You. Only for a repository with zero required reviews
TFPR_PLAN_ENVIRONMENT, TFPR_APPLY_ENVIRONMENT Repository variables You, and only if you rename the environments
Engine version pins in the four caller workflows Repository files You. The bootstrap writes no files
Tree placeholders (businessunit, amba_action_group_email) Repository files You. The bootstrap writes no files
CODEOWNERS, LICENSE Repository files You

The bootstrap does no templating. It creates the repository with the GitHub template feature and never writes into a file, so every file in a generated repository is a verbatim copy of the template at the moment of creation. Values reach the tree at run time through the Action variables above, which root.hcl reads with get_env.

INFRACOST_API_KEY is the one gap that affects a gate. Until it is set, the cost gate notes it is missing and skips. The infracost binary itself is already in the TFPR_EXTRA_TOOLS value the bootstrap writes, so check that variable before editing it. See The gates.

Keep the environment names

The plan and apply environments must exist and keep those names. The OIDC subject embeds the environment claim, so a renamed environment produces a token that matches no federated credential, and every Azure job fails at login.

Outputs

Output Value
state_resource_group_name Resource group holding the state storage account
state_storage_account_name State storage account name, also written as BACKEND_AZURE_STORAGE_ACCOUNT_NAME
state_container_name State container name, tfstate
plan_identity_client_id Client id of the plan identity, also written as AZURE_CLIENT_ID in the plan environment
apply_identity_client_id Client id of the apply identity, also written as AZURE_CLIENT_ID in the apply environment
plan_identity_principal_id Object id of the plan identity, for role assignment auditing
apply_identity_principal_id Object id of the apply identity, for role assignment auditing
repository_full_name The generated customer repository, org/repo
repository_html_url URL of the generated repository
runner_egress_ip Public egress IP of the self-hosted runners, allowlisted on the state firewall. Null on github_hosted, and also null on self_hosted_private with nat_gateway_enabled = false
engine_selftest_plan_identity_client_id Client id of the engine self-test plan identity, also written as AZURE_CLIENT_ID in the engine repository's plan environment. Null unless engine_selftest_repository is set
engine_selftest_state_storage_account_name Engine self-test state account, also written as TFPR_LOCK_STORAGE_ACCOUNT on the engine repository. Null unless engine_selftest_repository is set
engine_selftest_init_args terraform init arguments for the examples/prod baseline apply, also written as TFPR_INIT_ARGS on the engine repository. Null unless engine_selftest_repository is set