Skip to content

First plan to first apply

The end-to-end walkthrough for onboarding your first customer. It starts at an empty GitHub organization and a customer Azure tenant, and ends with an applied landing-zone foundation.

Six steps. Budget half a day of hands-on work, plus sixty to ninety minutes of waiting on the first apply.

Step What it does Hands-on time
1 Run the bootstrap 30 to 45 min
2 Fill in the customer values 20 min
3 Repo settings the bootstrap does not cover 10 min
4 First pull request and plan 10 min
5 First apply 5 min, then 60 to 90 min waiting
6 What next -

Before you start, work through the prerequisites. You need Owner at the scope the deploy identities operate at, a management subscription, a GitHub organization for the customer, a GitHub token with repo and admin:org scope in that organization, and the engine App credentials NRIT issued for it.

1. Run the bootstrap

The bootstrap is Terraform in nrit-solutions/nrit-alz-bootstrap. It runs once per customer, against the customer tenant. Full detail is on the bootstrap page; this is the shape of the run.

# Copy customers/_example.tfvars to customers/<customer>.tfvars and fill it in.
az login --tenant <customer-tenant-id>
az account set --subscription <management-subscription-id>

export GITHUB_TOKEN=<pat>                       # repo + admin:org on the customer org, never in tfvars
export TF_VAR_github_runners_token=<pat>        # self_hosted_private only
export TF_VAR_engine_app_private_key="$(cat /path/to/engine-app.pem)"
export TF_VAR_checks_app_private_key="$(cat /path/to/checks-app.pem)"

scripts/create-backend.sh -f customers/<customer>.tfvars

terraform init \
  -backend-config="resource_group_name=rg-cicd-bootstrap-<loc>" \
  -backend-config="storage_account_name=<printed by the script>" \
  -backend-config="container_name=tfstate" \
  -backend-config="key=bootstrap.tfstate"

terraform plan  -var-file=customers/<customer>.tfvars
terraform apply -var-file=customers/<customer>.tfvars

After the apply, the customer has:

  • the customer repository, generated from nrit-alz-customer-template, so it starts with the whole live/ tree and the four caller workflows,
  • the deploy identities, plan (Reader) and apply (Owner), with GitHub Actions federated credentials, so no Azure secrets are stored,
  • the plan and apply environments, each carrying its own AZURE_CLIENT_ID,
  • the state storage account and tfstate container, Entra ID auth only,
  • the require-approved-pr-to-main ruleset, requiring an approved review to merge (the merge-gate check is required only once you add it, see below),
  • the self-hosted runner, when network_posture = self_hosted_private.

The backend firewall allows one IP

scripts/create-backend.sh resets the allowlist to your current public IP. If your IP changes, re-run it before any terraform command. On the very first run the operator role assignment can take a minute to propagate before terraform init succeeds.

2. Clone the customer repository and set the customer values

gh repo clone <customer-org>/<customer-repo>
cd <customer-repo>
git checkout -b onboarding

Five files hold values that are specific to this customer.

live/tenant.hcl

tenant_root_id is the management group the ALZ hierarchy is created under. It defaults to local.tenant_id, which is the tenant root group. That is what most customers want.

tenant_root_id = local.tenant_id

Set an intermediate management group before the first apply

If the customer already has an intermediate management group and wants the hierarchy under it, set tenant_root_id to that group's plain name now. The group must already exist; this repository does not create it.

Changing it after the first apply moves the whole hierarchy to a new parent. Policy assignment scopes and role assignments follow the move, and subscriptions can be left unplaced while it runs. That is a migration, not an edit.

live/_foundation/region.hcl

locals {
  location       = "westeurope"
  location_short = "weu"
  environment    = "prod"
}

Change location and location_short only if the customer specifies another region. Set them here and nowhere else: root.hcl generates a context.tf into each unit from this file. environment becomes the env tag. The tag policy allows prod, staging, and dev only, so a foundation that is not the customer's production estate must use one of those.

live/_foundation/management-resources/main.tf

Replace the businessunit tag, currently changeme, with the customer's short name.

businessunit    = "changeme"

live/_foundation/amba/main.tf

Replace the amba_action_group_email placeholder with a real monitored inbox. Azure Monitor Baseline Alerts is mandatory in the NRIT baseline and every AMBA alert routes to this address.

amba_action_group_email = "alerts@example.com"

.github/CODEOWNERS and LICENSE

.github/CODEOWNERS assigns every path to @nrit-solutions/platform-engineering. That team does not exist in the customer's organization, so GitHub reports the file as invalid and the owners never apply. Replace the team with one that exists there, or delete the file.

LICENSE ships as a placeholder that points at the partnership agreement. Replace it with the agreed license text, or confirm with the agreement owner that the placeholder is acceptable for this customer.

Optional: a connectivity subscription

live/_foundation/landing-zones/main.tf has connectivity_subscription_id, which defaults to an empty string and omits the connectivity entry from subscription_placement. Set it when the customer has a connectivity subscription. Placement is not permanent: set the id later and re-apply to move the subscription under the Connectivity MG.

Some values are never edited in files

The tenant id, subscription id, and the three backend names arrive as repository variables the bootstrap wrote (AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID, BACKEND_AZURE_*). The placeholders in live/tenant.hcl and live/_foundation/subscription.hcl are only used for local runs. All units share the one state account; each folder's subscription.hcl is the deploy target, not the state location. See secrets and variables.

The engine pins in the four caller workflows (tf-pr-ops-pr.yml, tf-pr-ops-unlock.yml, tf-pr-ops.yml, drift.yml) already sit at the template's current release, matching the version statement. Leave them alone for the first onboarding.

Generate the provider lock files

The template deliberately ships no .terraform.lock.hcl files, so this repository pins its providers at onboarding, to current versions. Generate one per unit and commit them with the value changes above:

for u in live/_foundation/*/; do
  terragrunt --working-dir "$u" init -backend=false
  TG_NO_AUTO_INIT=true terragrunt --working-dir "$u" run -- providers lock -platform=darwin_arm64 -platform=linux_amd64
done

The second command adds the linux_amd64 hashes (TG_NO_AUTO_INIT keeps Terragrunt from initializing the real backend on the way in). The engine restores Terraform providers from the Actions cache and verifies every cached package against the lock file on the runner, so a lock file written on a Mac alone makes the cache miss and the provider download run on every job. Add your own platform in place of darwin_arm64 if it differs.

Until they exist, provider versions re-resolve on every run, so an /apply can use a different version than the plan that was reviewed. The pre-commit invariants check warns about it, without blocking. Why the template ships none, and how to move a provider version later, is on Versions and upgrades.

3. Repo settings the bootstrap does not cover

Four things to check before the first pull request.

The engine App credentials. The caller workflows reference the public nrit-solutions/tf-pr-ops entrypoint, which needs no access grant, and check the private engine core out with a token minted from ENGINE_APP_CLIENT_ID and ENGINE_APP_PRIVATE_KEY. The bootstrap set both when engine_app_client_id was supplied; confirm they are there. Without them the first pull request fails at the dispatch step with a message naming the missing variable.

The checks App. The engine's check rows (the merge gate included) are written under the org's checks App. Create it once per org and install it with "Only select repositories"; the bootstrap does the rest when the tfvars sets checks_app_client_id, checks_app_installation_id, and checks_app_id and TF_VAR_checks_app_private_key is exported. See the checks App.

The cost gate key. INFRACOST_API_KEY is not set by the bootstrap either. Add it as a repository secret.

gh secret set INFRACOST_API_KEY --repo <customer-org>/<customer-repo>

The bootstrap already sets TFPR_EXTRA_TOOLS and its value includes infracost. Check the variable rather than editing it blindly.

The required status check. Confirm tf-pr-ops / merge-gate is required on the main branch.

gh api repos/<customer-org>/<customer-repo>/rulesets

The require-approved-pr-to-main ruleset takes the check context from the bootstrap's required_status_checks variable, which is empty by default. Set it to ["tf-pr-ops / merge-gate"] in the customer tfvars and re-run the bootstrap, or add the check by hand.

Check the App credentials too

The engine checkout needs ENGINE_APP_CLIENT_ID (variable) and ENGINE_APP_PRIVATE_KEY (secret). The bootstrap sets both when engine_app_client_id is supplied and the key is exported.

4. Open the first pull request

git commit -am "onboarding: set customer values"
git push -u origin onboarding
gh pr create --fill

Opening the PR runs /plan automatically. The engine plans every impacted unit, one job per unit, in parallel. Because you touched live/tenant.hcl, which every unit reads, expect the full foundation fan-out.

What to look for:

  • A plan comment per unit. Each run posts a fresh comment led by a validation check, then the plan output, a change summary, the /apply instructions, and the gate findings.
  • The gate panel. It is collapsed with a check mark when policy, security, and cost are all clean, and auto-expanded with a warning when a gate reports something.
  • The tf-pr-ops / merge-gate check. It is red until the PR is applied. That is the point: nothing merges with unapplied changes.

Read the plans before approving. The first foundation plan creates the whole management group hierarchy, the policy assignments, the central Log Analytics workspace, and the AMBA alerts. See plan and apply for how the impacted set is worked out.

5. First apply

Get the review. /apply respects the repository's review decision, and the bootstrap defaults the required approving review count to 1.

Then comment on the PR:

/apply

The foundation applies in dependency order automatically: management-resources, then landing-zones, then amba. One unit at a time. The engine reacts to your comment so you can follow progress without opening the run.

Expect sixty to ninety minutes

The first foundation apply is slow because of policy propagation. This is normal and there is nothing to fix. Later applies on the same foundation are much faster.

Success looks like this:

  • every unit reports Applied in its comment,
  • the tf-pr-ops / merge-gate check turns green,
  • the PR can merge.

Merge the PR. Nothing applies on merge; the apply already happened from the comment.

6. What next

  • Growing the tree: onboard a connectivity hub and the customer's first landing zones.
  • Plan and apply: the day-to-day flow, the comment commands, and how the impacted set is calculated.
  • Policy: what the baseline enforces and how to change it.