Skip to content

First plan to first apply

The end-to-end walkthrough for onboarding your first customer. It starts at an empty GitHub organisation 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 organisation for the customer, and a GitHub token with repo and admin:org scope.

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 <bootstrap-subscription-id>

export GITHUB_TOKEN=<pat>                       # repo + admin:org, never in tfvars
export TF_VAR_github_runners_token=<pat>        # self_hosted_private only
export TF_VAR_catalog_app_private_key="$(cat /path/to/catalog-reader-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 both 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, which is the merge gate,
  • 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 organisation, 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 licence 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 .github/workflows/terraform-pr-ops.yml and .github/workflows/drift.yml are already at v1.5.0 on both the uses: ref and engine_ref. Leave them alone for the first onboarding.

3. Repo settings the bootstrap does not cover

Three things to check before the first pull request.

Org Actions access to the private engine. The caller workflows use the PR-ops engine (nrit-tf-pr-ops) reusable workflows. Without this, the uses: reference fails to resolve and the first pull request never starts.

gh api -X PUT repos/nrit-solutions/nrit-tf-pr-ops/actions/permissions/access \
  -f access_level=organization

This is an org-wide setting on the NRIT source repository. Set it once and it holds for every later customer.

The cost gate key. INFRACOST_API_KEY is the only thing the bootstrap does not set. 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 CATALOG_APP_CLIENT_ID (variable) and CATALOG_APP_PRIVATE_KEY (secret). The bootstrap sets both when a client id is supplied. The names are historical: the App token authenticates the private engine checkout, not a module catalog.

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 status. 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.