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 |
Related¶
- Bootstrap a customer, the procedure
- Secrets and variables, what the repository reads
- The PR-ops engine, what consumes all of this
- The runner and the engine app
- Security
- Troubleshooting