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 |
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 organisation 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 customisation | 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. Stale reviews dismissed on push. Organisation admins can bypass, for break-glass |
| 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, and CATALOG_APP_CLIENT_ID when a client id is set |
| Repository secret | CATALOG_APP_PRIVATE_KEY, when catalog_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 registered with a PAT |
| 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 | A public IP the bootstrap owns, attached to the module's NAT gateway. This is the single address allowlisted on the state firewall |
| State access | A private endpoint for the state account's blob service, plus a privatelink.blob.core.windows.net private DNS zone linked to the runner network |
| Compute | Container App Jobs by default, scaling to zero when idle. Container Instances as the always-on alternative |
| Registry | The module's ACR, used when building a custom runner image |
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.
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" |
Organisation 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 |
catalog_app_client_id |
"" |
Client id of the GitHub App, set as the CATALOG_APP_CLIENT_ID repository variable. Empty skips it |
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 organisation 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 TF_PR_OPS_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.
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 |
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 |
runner_image_commit |
null |
The nrit-alz-bootstrap git ref holding runner-image/github-runner-aca. Set it to build a custom runner image carrying python3, checkov, and conftest. Null keeps the AVM default image. 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 four 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. Authenticates the github provider |
TF_VAR_github_runners_token |
self_hosted_private |
PAT that registers the self-hosted runners, with repo or org admin scope. Falls back to github_token |
TF_VAR_catalog_app_private_key |
when catalog_app_client_id is set |
GitHub App private key, PEM. Set as the CATALOG_APP_PRIVATE_KEY repository secret. Omitting it destroys any existing secret |
TF_VAR_runner_image_context_token |
optional | PAT the ACR build task uses to clone the context repo for a custom runner image. Falls back to github_token |
GITHUB_TOKEN is not always enough
GITHUB_TOKEN only authenticates the provider. Building a custom 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.
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 |
CATALOG_APP_CLIENT_ID |
Repository variable | Bootstrap, when a client id is supplied |
CATALOG_APP_PRIVATE_KEY |
Secret | Bootstrap, when a private key is supplied |
INFRACOST_API_KEY |
Secret | You. The bootstrap never sets it |
TF_PR_OPS_ALLOW_UNREVIEWED_APPLY |
Repository variable | You. Only for a repository with zero required reviews |
TF_PR_OPS_PLAN_ENVIRONMENT, TF_PR_OPS_APPLY_ENVIRONMENT |
Repository variables | You, and only if you rename the environments |
| Engine version pins in the two 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 |
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