# Landing Zones as Code: Modern Azure Foundations with GitOps

This post was written for the **Azure Spring Clean 2026** event. Visit the official [**website**](https://www.azurespringclean.com/) to see the schedule and links to great content.

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/52eb8015-c43f-4956-8ac9-ccc60b66e436.png align="center")

A year ago, I wrote an [article](https://azurescholar.cloud/new-alz-accelerator-lessons-learned) for this community event titled **New ALZ Accelerator: Lessons learned**, where I shared my experience with pros and cons of using that accelerator. My feedback was overall positive but I wanted to have more granular control over the configuration of platform LZ, the level of abstraction was too much for me.

A lot has changed since that time, most notably the fact that the *starter modules (for both Bicep and Terraform)* are now using [Azure Verified Modules](https://aka.ms/avm), my second most favorite project.

> Azure Landing Zones are the foundation for secure, scalable, and well-governed Azure environments—but getting from theory to a production-ready setup can still feel heavy.

In this article, we’ll take a look at the **new** **Azure Landing Zone IaC Accelerator**, how this tool simplifies the deployment and operation of Azure Landing Zones using Infrastructure as Code and GitOps.

You’ll see how this approach enables repeatability, consistency, and safer changes across environments—while aligning platform engineering practices with real-world operations.

## Plan first, bootstrap later

The entire Accelerator experience is organized into **four distinct phases**, each having specific inputs and outputs, that are then used in the subsequent phase.

The first one, called **Planning**, allows you to capture essential inputs and design decisions for your Azure Landing Zone. It is typically executed as a workshop, where you, as the facilitator, invite key stakeholders - from business, to engineering, operations, and security - and provide them with a set of design questions with available options.

These could range from complex topics like *network topology* to simpler ones, such as *What IaC tool do we want to use?* or *What SDLC platform should we choose? GitHub, Azure DevOps, or something else?*

> “If you fail to plan, you are planning to fail!” (Benjamin Franklin)

This might sound like an unnecessary overhead, but I would strongly encourage you to have a report or a set of Architecture Decision Records (ADRs) from this workshop, that your stakeholders formally sign-off. It will save you from disputes about what was agreed and approved. You don't want to rebuild the entire setup a few weeks or months after the initial deployment!

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">This rule is even more important if you aren't deploying ALZ in your own organization, but you are doing it as an MSP for your customers. The report should be added to project documentation, so any significant change in the setup can be handled as a change request with an adequate compensation for the 'rework'.</div>
</div>

Luckily, the ALZ team provides you with two **assets** that can help you with collecting the inputs:

*   **configuration files** - two yaml files that are used by the `ALZ` PowerShell module as the orchestrator in the Bootstrap phase.
    
*   **checklist** - an [Excel spreadsheet](https://azure.github.io/Azure-Landing-Zones/examples/tf/accelerator/config/checklist.xlsx) that helps you gather the required information. It is a bit more presentable for non-technical stakeholders.
    

<div data-node-type="callout">
<div data-node-type="callout-emoji">❗</div>
<div data-node-type="callout-text">I will be focusing on my experience with Bicep - GitHub combination, so the behavior will be different, if you use Terraform or ADO!</div>
</div>

Both assets have a clear separation between **Bootstrap** and **Deploy** phases:

*   You mark your Bootstrap choices in the `Accelerator - Bootstrap` sheet and the `inputs.yaml` file
    
*   You specify your Deploy choices in the `Accelerator - Bicep` sheet and the `platform-landing-zone.yaml`.
    

### Bootstrap-related decisions

There is a number of [bootstrap decisions](https://azure.github.io/Azure-Landing-Zones/accelerator/0_planning/#3---bootstrap-decisions) you need to make, from the IaC tool you prefer, your SDLC tool of choice, support for private agents/runners and private networking.

Here is how my `inputs.yaml` file look like:

```yaml
iac_type: "bicep"
bootstrap_module_name: "alz_github"
starter_module_name: "platform_landing_zone"
bootstrap_location: "norwayeast"
root_parent_management_group_id: "" # Leave empty to use Tenant Root Group
use_self_hosted_runners: false
use_private_networking: false
github_personal_access_token: "github_pat_xxxxxx" # Can also be supplied via environment variable TF_VAR_github_personal_access_token
github_organization_name: "my-enterprise-org"
apply_approvers: ["david.pazdera@cegal.com"]
subscription_ids:
  management: ""
  connectivity: ""
  identity: ""
  security: ""
bootstrap_subscription_id: ""
```

You will need at least four (or eventually five) empty subscriptions that will represent different parts of your platform.

<div data-node-type="callout">
<div data-node-type="callout-emoji">⚠</div>
<div data-node-type="callout-text">I tried the bootstrap with only two subscriptions - management and connectivity - but I got an error about missing config keys under the <code>subscription_ids</code>, as you can see in the screenshot below.</div>
</div>

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/31733b73-178c-43f8-8bb1-104ab0214eb4.png align="center")

Please note, that you need a GitHub organization account, not just your private account. When creating [PAT token(s)](https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/github/#github-personal-access-token-pat), make sure you are selecting correct account when granting permissions, it needs to match with `github_organization_name`.

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">I was using an organization that is part of GitHub Enterprise Cloud account, but for testing you could create a free organization. You won't be able to configure all security and governance features though.</div>
</div>

### Starter module decisions

I have also noticed that the checklist spreadsheet got simplified (comparing to the previous versions), especially that `Accelerator - Bicep` **starter module** sheets, with decisions like "Deploy Bastion Host: Yes/No" (like you can see below):

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/4ccf89ea-d565-47f0-b070-f238b150a963.png align="center")

The problem is, that these *decisions* don't have any direct mapping to `platform-landing-zone.yaml` config file, which is confusing to say the least.

I was curious if those two yaml files have some sort of 'schema', so I can better understand what behavior I can influence by using specific configuration keys and values:

*   I found one for the *bootstrap* phase [here](https://github.com/Azure/ALZ-PowerShell-Module/blob/main/src/ALZ/Private/Deploy-Accelerator-Helpers/AcceleratorInputSchema.json) and a quite well documented [example](https://github.com/Azure/alz-bicep-accelerator/blob/main/examples/bootstrap/inputs-github.yaml) for Bicep - GitHub combo. If you are familiar with Terraform, you can also inspect this [variables.tf](https://aka.ms/alz/acc/bootstrap/github/variables) file.
    
*   For the *Bicep starter module*, there is [ALZ-Powershell.config.json file](https://github.com/Azure/alz-bicep-accelerator/blob/main/.config/ALZ-Powershell.config.json) in the `Azure/alz-bicep-accelerator` repository that could be treated as a schema file, or at least it shows the keys and default values. For most common input parameters, you can check the example [here](https://github.com/Azure/alz-bicep-accelerator/blob/main/examples/platform-landing-zone.yaml).
    

These yaml files, however, give you only **limited control** about what gets provisioned to your Platform landing zone. Don't worry, you will get much more granular control after you run the next phase.

Regardless what method for collecting those inputs you select, you will need to provide them as those `.yaml` files, when you run the ALZ Accelerator... or you can decide to use an [**interactive mode**](https://azure.github.io/Azure-Landing-Zones/accelerator/2_bootstrap/#interactive-mode), where the Accelerator asks you about those points, but only for the bootstrap part. You need to update your platform landing zone configuration file with the required values!

## Prerequisites - tools and permissions

I bet you are eager to get practical, but there is one step between planning and bootstrapping: we need to ensure our machine has necessary tools and we have correct accounts and permissions to continue without errors.

### Machine

I was running the deployment on a MacBook and all the tools that were needed, I installed using Homebrew, but I bet you could do the same thing with WinGet or a similar package manager on Linux. All you need is:

```text
- PowerShell 7.4+
- Azure CLI 2.55.0+
- Git
- Coding editor (not required but recommended)
```

With these tools in place, you need to start a PowerShell session, where you first install the **ALZ module** from the PowerShell Gallery: `Install-PSResource -Name ALZ`

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">TIP: Once you have that module installed, you can easily validate if your machine has all the required tools by running the <code>Test-AcceleratorRequirement</code> cmdlet.</div>
</div>

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/d6f7bb09-0da4-44f7-8729-fd83a039165c.png align="center")

### Accounts and permissions

Apart from having those four empty Azure subscriptions (located under to 'Root Tenant' in the Management Group hierarchy), you need quite high permissions in Azure and Microsoft Entra.

*   **Owner** role on the chosen "top level" management group (in my case it was the 'Tenant Root Group')
    
*   **User Access Administrator** role on the `/` root tenant level *(required for AVM Bicep only)*
    
*   **Owner** role for the chosen subscriptions
    

Regardless if you have those permissions assigned statically or you use PIM, you will need to sign-in to your terminal session to the "target tenant":

```shell
az login --tenant "3877c9d0-cd6e-40ea-a2df-3da9634d92e7" --use-device-code
```

After signing using the device code and confirming the MFA challenge (or ideally using a **passkey** to authenticate to Entra ID), you should get a list of available subscriptions. Select the one you dedicated to *Bootstrap resources*. In my case, I used the same subscription as for the *Management* *LZ.*

You should double-check you have the right permissions, either by using the Portal, or Azure CLI, for example:

```shell
USER_OBJECT_ID=$(az ad signed-in-user show --query id -o tsv)
TENANT_ID=$(az account show --query tenantId -o tsv)
MG_NAME="$TENANT_ID"
MG_SCOPE="/providers/Microsoft.Management/managementGroups/$MG_NAME"
SUBSCRIPTION_ID=$(az account show --query id -o tsv)
SUB_SCOPE="/subscriptions/$SUBSCRIPTION_ID"

# Check User Access Administrator at the root level
echo "Checking UAA role at the root level for $USER_OBJECT_ID"
az role assignment list --assignee "$USER_OBJECT_ID" --role "User Access Administrator" --scope "/" -o table

# Check Tenant Root Group Ownership
echo "Checking Owner at Tenant Root Group ($MG_SCOPE) for $USER_OBJECT_ID"
az role assignment list --assignee "$USER_OBJECT_ID" --role "Owner" --scope "$MG_SCOPE" --include-inherited true -o table

# Check subscription ownership
echo "Checking Owner on subscription $SUBSCRIPTION_ID for $USER_OBJECT_ID"
az role assignment list --assignee "$USER_OBJECT_ID" --role "Owner" --scope "$SUB_SCOPE" --include-inherited true -o table
```

Check [this article](https://azure.github.io/Azure-Landing-Zones/accelerator/1_prerequisites/platform-subscriptions/) for more details and explanations, how these permissions are being used.

## Bootstrap your GitOps setup

Depending on your chosen IaC language, VCS, and self-hosted runners/agents choices, the `Deploy-Accelerator` command will create the following resources (or a subset of those):

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/d4ee45fd-d55b-4b47-8c8e-af4f1a1a3eb5.png align="center")

### Gain full control over the process

Since I prefer having a maximum control over the process, instead of using the interactive mode, I went for the [advanced bootstrap](https://azure.github.io/Azure-Landing-Zones/accelerator/2_bootstrap/advanced/) option with my own twist:

*   Initiate a folder structure to hold the configuration files for the bootstrap deployment: `New-AcceleratorFolderStructure -iacType bicep -versionControl github -targetFolderPath "~/repos/alz-acc-test-deployment"`
    

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/5872cf07-a612-4888-8a20-8ae766be1a6b.png align="center")

*   Open the newly created configuration folder in VS Code: `code "~/repos/alz-acc-test-deployment/config"`
    
*   **Update the** `inputs.yaml` **file** located in the `config` folder with the agreed-upon values from the planning phase. Ensure all required fields are filled out correctly. Pay attention to these parameters that shape the **naming convention** of your bootstrap resources:
    
    *   `service_name` is used to build up the default resource names. Only lowercase letters and numbers are allowed. Example: rg-**<service\_name>**\-mgmt-uksouth-001
        
    *   `environment_name` is used to differentiate between multiple environments (e.g., dev, test, prod). Used to build up the default resource names.Only lowercase letters and numbers are allowed. Example: rg-alz-**<environment\_name>**\-uksouth-001
        
    *   `postfix_number` is used to build up the default resource names. Only numbers are allowed. Default is `1`. Example: rg-alz-mgmt-uksouth-**<postfix\_number>**
        
    *   `apply_approvers` is a list of email addresses of approvers for the apply stage in GitHub Actions. Must be a list of GitHub usernames or team slugs. Example: \["user1", "user2", "org/team-name"\]
        
*   **Update the** `platform_landing_zone/inputs.yaml` **file** located in the same directory:
    
    *   At least one parameter require an update: `starter_locations: ["norwayeast"]`
        
    *   You can use the prefix and postfix variables to add a consistent naming convention across all management groups. For custom management group structures, you can modify the Bicep code directly after running the bootstrap and prior to deploying the Platform Landing Zone.
        
    *   You can influence the naming convention of Resource Groups by modifying `resource_group_{type}_prefix:` keys, e.g., `resource_group_logging_name_prefix:`
        
*   Now you are finally ready to run the bootstrap by using this command:
    

```shell
$targetFolderPath = "~/repos/alz-acc-test-deployment/"

Deploy-Accelerator -inputs "$targetFolderPath/config/inputs.yaml", "$targetFolderPath/config/platform-landing-zone.yaml" -output "$targetFolderPath/output"
```

This cmdlet has many parameters that can be used to customize the deployment process - for example: `-bootstrap_module_version, -starter_module_version, -auto_approve, -skip_requirements_check` but I haven't used them.

Behind the scenes, it leverages **Terraform** to provision all required resources using respective providers:

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/e41dc235-3e13-408f-a703-94805b7f918b.png align="center")

<div data-node-type="callout">
<div data-node-type="callout-emoji">❔</div>
<div data-node-type="callout-text">You might be wondering if you need to handle Terraform state file or commit the HCL config files to a version control. The short answer is <strong>"No"</strong>. This is a one-off operation and the Accelerator doesn't currently support managing the bootstrap configuration in VCS.</div>
</div>

### Validate the result

After the bootstrap deployment is complete, the following subdirectories and files are in the **bootstrap directory**:

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/53a2c7e9-0faa-4562-9880-1e22b0e542c4.png align="center")

As you can see, the tool pulled the 'latest' version of bootstrap and starter packages from their respective sources locally and used them in that Terraform flow.

More interesting is the view on the **GitHub side**. Since I decided to set the `use_separate_repository_for_templates` key to 'true', I got two repositories: one containing reusable (called) workflows:

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/06f86786-feb8-4d15-b7e0-70c90643b13f.png align="center")

... and another one storing the actual platform LZ configuration:

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/339bc9f9-e1dd-44cf-a7c7-46454764abc9.png align="center")

This repository was configured with:

*   a branch policy for the `main` branch requiring pull requests to be reviewed and approved before merging
    
    *   Require a PR before merging, Org and repository admins can dismiss PR reviews
        
    *   Require conversation resolution before merging
        
    *   Require linear history
        
    *   Do not allow bypassing the above settings
        
*   two environments named `Plan` and `Apply`
    
*   a GitHub team - `alz-mgmt-approvers` that is authorized to approve deployments to production environment in the Continuous Deployment workflow.
    
*   a collection of repository and environment variables:
    

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/29715bba-ef5b-4295-97c7-aa37a6ef2d94.png align="center")

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">The Accelerator doesn't create secrets but prefers variables for storing key values for OIDC authentication. This is because secrets are encrypted and not visible in the GitHub Actions logs, while variables can be used directly in the workflows without additional steps. This makes the troubleshooting and debugging of workflows easier.</div>
</div>

On the **Azure side**:

*   two managed identities were created in the bootstrap subscription
    
*   these UAMIs were assigned the following **custom RBAC roles**:
    

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/8e675b01-54b3-43ea-ae7e-0419517f662b.png align="center")

It is important to note, that at this stage, there are **no changes made to the Management Groups, policies, and platform resources**, except the creation of the bootstrap resources in the bootstrap subscription (in our case the same as the management LZ subscription).

## Customize your Platform LZ configuration

We still have the option to shape and fine-tune the way our *platform landing zone* will look like, thanks to more modular approach and the fact this new accelerator uses Azure Verified Modules.

You can start by cloning your newly created GitHub repository to your dev machine:

```shell
gh repo clone my-org/alz-mgmt
code alz-mgmt
```

**For example:** You want to [modify the Management Group hierarchy](https://azure.github.io/Azure-Landing-Zones/bicep/howtos/modifyingmghierarchy/) or choose, [what policies are assigned](https://azure.github.io/Azure-Landing-Zones/bicep/howtos/modifyingpolicyassignments/) or not assigned at what scope. You would follow those guides and modify respective `.bicepparam` files (you shouldn't have a need to modify any of the `main.bicep` files).

Here I am removing the 'Identity' Management Group from the hierarchy:

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/38820cea-7918-4bf3-9864-c5adb3098b70.png align="center")

<div data-node-type="callout">
<div data-node-type="callout-emoji">❗</div>
<div data-node-type="callout-text">ALZ Accelerator contains a large collection of policies that get assigned, some audit resource configuration, some prevent insecure resources, others enable services - like Microsoft Defender for Cloud - that will have an impact on your Azure bill. You should spend time reviewing <a target="_blank" rel="noopener noreferrer nofollow" class="text-primary underline underline-offset-2 hover:text-primary/80 cursor-pointer" href="https://azure.github.io/Azure-Landing-Zones/policy/policyassignments/" style="pointer-events: none;">those policies</a>, their effect, and impact on your cloud foundation and its cost, before you trigger the deployment!</div>
</div>

Once you are happy with your changes, simply create a new branch, stage and commit the changed files, and push them back to GitHub:

```shell
git checkout -b config-update
git add .
git commit -m "Modified Bicep code to remove unused subscriptions"
git push -u origin config-update
```

## Final phase: Run

### Review your configuration changes first

If you made any changes to the configuration, you need to **create a Pull Request** to merge those changes to the `main` branch. This will start the Continuous Integration (CI) workflow, which will validate the changes:

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/88eb4cba-1344-4e12-a070-a09aacc3266e.png align="center")

### Bring popcorn and watch the deployment

Once the CI workflow completes successfully, you can merge the pull request into the `main` branch. This will trigger the **Continuous Delivery** (CD) workflow, which will **finally** deploy the Platform Landing Zone to Azure. 😎

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/7462ae1e-a0e1-4e82-b9e6-e91825fa5cfb.png align="center")

Once the CD workflow reaches the "Deploy" stage, it will require approval from one of the designated approvers. You can approve the deployment by navigating to the "Actions" tab of the GitHub repository and selecting the "Deploy" job.

The workflow will continue and deploy the Platform Landing Zone to Azure. From this list of tasks you can see how is the deployment sequenced:

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/ce9bdf09-21ed-40f2-9b9c-28fddb4aa2d0.png align="center")

### Final validation

If you don't get any errors in the workflow run, you should verify the result in the Azure Portal and see if:

*   You have the full Management Group hierarchy with platform subscriptions in correct MGs:
    

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/57b8377d-dd9b-4f87-b457-2cbbd2c57e9e.png align="center")

*   Custom roles available in the environment:
    

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/542f7828-ebbb-4e6c-994e-8c781a256368.png align="center")

*   ALZ Policies assigned at the right scopes:
    

![](https://cdn.hashnode.com/uploads/covers/659fb683e980d0e3a7d1e0ba/bbb545d9-a4a3-4bb5-8d96-05e2d62afb82.png align="center")

*   Your Platform subscriptions contain resources such as Log Analytics workspace, Data Collection Rules, Action Groups, Alert Rules, Hub VNet with Azure Firewall, etc.
    

## Conclusion

My overall experience has been very positive:

*   the [documentation](https://azure.github.io/Azure-Landing-Zones/accelerator/) has improved a lot,
    
*   the modularity of the new Accelerator is a great improvement (ALZ ❤️ AVM),
    
*   you are in better control and you can customize your setup way more than with the previous version.
    

There are still a few things on my **wish list** I would like to see in future releases like tag-driven enablement of Microsoft Defender for specific resources. My customers are ok to pay for extra protection of their production resources, but why should they enable those expensive features on dev and test environments?

## Join the ALZ community

I would encourage you to join **ALZ External Community calls** by signing-up here: [aka.ms/alz/communitycall](https://aka.ms/alz/communitycall)

If you can't join them directly, you could always watch the recordings on YouTube:

%[https://www.youtube.com/@MicrosoftCAE]
