Azure Landing Zone with GitLab CI/CD
Automate Azure Landing Zone deployments with GitLab CI/CD and OIDC.
Introduction
30 Second Summary
Imagine five teammates all making changes to the same cloud environment by typing commands into their own terminals. Without a system to review and track every change, one wrong command could break production with no way to trace what happened.
In this project, you will push your Azure Landing Zone Bicep templates into a GitLab repository and build a CI/CD pipeline that takes over the deployment workflow. Instead of running commands by hand, you will edit a file, push the change, and watch the pipeline automatically validate and deploy it to Azure.
What You'll Build
You will edit a Bicep file in VS Code, push it to GitLab, and watch the pipeline run a what-if preview before deploying the change live to your Azure tenant.
By the end of this project, you'll have:
- A GitLab repository holding your landing zone templates with full version history, so you can track every infrastructure change and review it before it goes live.
- Passwordless OIDC authentication between GitLab and Azure, with no client secrets stored anywhere.
- A two-stage pipeline that previews exactly what will change on every push and deploys automatically when you merge to main.
- Secret Mission: Add a Bicep lint stage that catches syntax errors and best-practice violations before they reach the what-if preview.
Are there any prerequisites?
This project builds on the Azure Landing Zone Governance with Bicep project. You need the management group hierarchy and policies from that project still deployed in your Azure tenant, along with VS Code and Azure CLI already installed.
You also need a GitLab.com Free tier account.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. You ran every deployment command by hand in the first project, so now you'll build a pipeline that validates and deploys your infrastructure changes automatically.
Install Git and Push Your Bicep Files to GitLab
Right now, your Bicep files live in a folder on your Desktop with no version history. If you accidentally delete a file or make a bad change, there is no way to roll back.
Without a remote repository, there is also no way for a CI/CD pipeline to pick up your changes or for teammates to review them. In this step, you will install Git, turn your project folder into a repository, and push your files to GitLab.
In this step, get ready to:
- Install Git on macOS and configure your identity.
- Create a new GitLab project and initialize your local repository.
- Push your existing Bicep files to GitLab.
Install Git and configure your identity
Git is the version control tool that tracks every change you make to your files. Before you can use it, you need to make sure it is installed on your Mac.
- Open your terminal by pressing Cmd+Space to open your search bar, then typing Terminal and pressing Enter.
- Check if Git is already installed by running this command:
git --version
✔️ I see a version number
Git is installed and ready to use. Move on to configuring your identity below.
ⓧ Command not found
Git is not installed yet. The simplest way to install it on macOS is through Xcode Command Line Tools.
- Install Git by running this command:
xcode-select --install
- A dialog appears asking you to install the tools. Click Install and wait for the download to finish.
- Once the installation completes, confirm Git is available by running:
git --version
You should see a version number printed in the terminal.
Still not seeing a version number?
Close and reopen your terminal, then run git --version again. Some installations require a fresh terminal session to register the new command.
help me troubleshoot why git --version is not working after installing Xcode Command Line Tools on macOS
Now tell Git who you are. Every commit records an author name and email, so Git needs these details before you can commit anything.
- Set your Git username by running this command:
git config --global user.name "[[YOUR_GIT_NAME="your full name"]]"
- Set your Git email by running this command:
git config --global user.email "[[YOUR_GIT_EMAIL="your email address"]]"
- Confirm your identity is saved by running:
git config --global --list
You should see your name and email in the output.
Why does Git need my name and email?
Every commit in Git is stamped with an author. This is how your team knows who made each change. The email does not need to match your GitLab account, but using the same one keeps your contributions linked.
Create a GitLab project and initialize your local repository
You need a remote repository on GitLab to store your Bicep files. You will create a blank project on GitLab.com, then initialize your local folder as a Git repository.
- Open your browser and sign in to GitLab.com.
- In the upper-right corner, click Create new (the + icon), then select New project/repository.
- Select Create blank project.
- In the Project name field, enter alz-governance.
- Set the Visibility Level to Private.
- Uncheck Initialize repository with a README. You are pushing an existing folder, so the repository must start empty.
- Click Create project.
GitLab shows your new empty project page. You will see instructions for pushing an existing folder.
Why uncheck the README?
If GitLab initializes the repository with a README, it creates a commit on the remote side. When you try to push your local commits, Git will reject the push because the two histories do not match. Starting with an empty remote avoids this conflict.
- Before you leave the GitLab page, copy the HTTPS URL for your project. It looks like https://gitlab.com/your-username/alz-governance.git.
Record your GitLab project URL here so you can use it in the next commands: https://gitlab.com/your-username/alz-governance.git
Now switch back to your terminal to initialize the local repository.
- Navigate to your existing Bicep project folder by running this command:
cd ~/Desktop/alz-governance
- Initialize a new Git repository with main as the default branch by running:
git init -b main
- Confirm the repository was created by running:
git status
You should see your Bicep files listed as untracked files. This confirms Git is now watching the folder.
What does git init -b main do?
The git init command turns a regular folder into a Git repository by creating a hidden .git directory inside it. The -b main flag sets the default branch name to main, which matches GitLab's default.
Create a personal access token and push to GitLab
GitLab requires a personal access token to authenticate Git pushes over HTTPS. You will create one now, then use it to push your files.
- In GitLab, click your avatar in the upper-right corner.
- Select Edit profile.
- In the left sidebar, select Access > Personal access tokens.
- From the Generate token dropdown, select Legacy token.
- In Token name, enter alz-git-push.
- Leave the Expiration date as the default (365 days).
- Under scopes, check write_repository.
- Click Generate token.
Copy your token now
GitLab shows your personal access token only once. Copy it and save it somewhere safe before leaving the page. You will paste it into the terminal in a moment.
Now return to your terminal to stage, commit, and push your files.
- Stage all files in the folder by running:
git add .
- Create your first commit by running:
git commit -m "Initial commit"
- Verify your commit was recorded by running:
git log --oneline
You should see one line showing your commit message Initial commit.
What do git add and git commit do?
The git add . command stages all files in the current directory, telling Git to include them in the next snapshot. The git commit command saves that snapshot with a message describing what changed.
- Connect your local repository to the GitLab remote by running:
git remote add origin [[GITLAB_REPO_URL="https://gitlab.com/your-username/alz-governance.git"]]
- Before you push, what do you think will happen? Will Git ask you for anything?
- Push your files to GitLab by running:
git push -u origin main
Git prompts you for credentials. Enter any non-empty string as the username (for example, your GitLab username). Paste your personal access token as the password.
Why does Git ask for a password?
GitLab uses your personal access token instead of a traditional password for HTTPS Git operations. When Git prompts for a password, paste the token you copied earlier. The characters will not appear on screen as you paste. This is normal.
Seeing an authentication error?
Make sure you selected the write_repository scope when creating your token. A token without this scope cannot push to repositories.
Double check that you unchecked Initialize repository with a README when creating the GitLab project. If the remote already has a commit, Git will reject your push.
help me fix a git push authentication error when pushing to GitLab over HTTPS with a personal access token
After a successful push, open your GitLab project in the browser and refresh the page.
- You should see managementGroups.bicep and policy.bicep listed in the repository file browser.
Your Bicep templates now have a permanent home with full version history. Next up, you will connect GitLab to Azure so the pipeline can authenticate and deploy changes on your behalf.
Connect GitLab to Azure with OIDC
Your Bicep files are version-controlled in GitLab, but a pipeline running there has no way to talk to your Azure tenant yet.
Storing a client secret in GitLab would work, but secrets expire, leak, and need rotation. OIDC federation solves this by letting GitLab present a short-lived JSON Web Token to Azure on every pipeline job. Azure validates the token, issues temporary credentials, and the credentials vanish when the job ends.
In this step, get ready to:
- Create an Entra ID application and service principal for GitLab.
- Create federated identity credentials linking your GitLab project to the Entra ID app.
- Grant the service principal Owner role at the tenant root scope and add CI/CD variables to GitLab.
Create an Entra ID application and service principal
An Entra ID application registration is the identity GitLab will use when it authenticates to Azure. A service principal is the corresponding account that Azure role assignments attach to.
- Switch to the terminal you used in Step 1.
- Create the Entra ID application by running this command:
az ad app create --display-name gitlab-oidc --query appId -otsv
What does this command do?
This registers a new application in Entra ID named gitlab-oidc. The --query appId -otsv flags strip the output down to the raw Application (client) ID, which you need in the next commands.
The command prints a single line: your Application (client) ID.
- Record your Application (client) ID: your application client ID
- Create the service principal for your new app by running this command:
az ad sp create --id [[AZURE_CLIENT_ID="your application client ID"]]
What is a service principal?
The application registration is the blueprint. The service principal is the actual identity object in your tenant that can receive role assignments and sign in. You need both: the app registration to configure federation, and the service principal to hold the Owner role.
Seeing a permission error?
Make sure you are signed in to Azure CLI as a user with Global Administrator access. Run az account show to confirm you are signed in to the correct tenant.
Help me troubleshoot my Entra ID app creation
Create federated identity credentials
Federated identity credentials tell Azure which external tokens to trust. You will configure Azure to accept OIDC tokens from your specific GitLab project on any branch.
- Get the Object ID of your application by running this command:
az ad app show --id [[AZURE_CLIENT_ID="your application client ID"]] --query id -otsv
- Record the Object ID: your application object ID
Now you need to create a JSON file that describes the trust relationship between your GitLab project and this Azure application.
- Retrieve your GitLab username by opening GitLab in your browser and clicking your avatar in the top-left corner.
- Your username appears below your name (it starts with @). Record it without the @ symbol: your GitLab username
- Create the body.json file by running this command in your terminal:
cat <<EOF > body.json
{
"name": "gitlab-federated-identity",
"issuer": "https://gitlab.com",
"subject": null,
"claimsMatchingExpression": {
"value": "claims['sub'] matches 'project_path:[[GITLAB_USERNAME=\"your GitLab username\"]]/alz-governance:ref_type:branch:ref:*'",
"languageVersion": 1
},
"description": "GitLab OIDC federation for alz-governance",
"audiences": [
"https://gitlab.com"
]
}
EOF
What is this JSON doing?
This file defines a flexible federated identity credential. The issuer tells Azure that tokens come from https://gitlab.com.
The claimsMatchingExpression uses a wildcard (ref:*) so the credential works on any branch. Without the wildcard, you would need a separate credential for every branch name.
The audiences value must match the aud claim you will set in your pipeline's id_tokens block later.
- Verify the file was created by running:
cat body.json
You should see the JSON you just created, with your GitLab username in the project_path value.
- Send the federated credential to Azure by running this command:
az rest --method POST --uri "https://graph.microsoft.com/beta/applications/[[APP_OBJECT_ID="your application object ID"]]/federatedIdentityCredentials" --body @body.json
Why use az rest instead of a dedicated command?
Flexible federated identity credentials with wildcard matching are managed through the Microsoft Graph API. The az rest command lets you call this API directly from the Azure CLI.
You should see a JSON response containing the federated credential details, including the name gitlab-federated-identity.
Seeing an error about the request body?
Double-check that you replaced your GitLab username in the body.json file. Run cat body.json to confirm. The project_path must match your GitLab project URL exactly (case-sensitive).
Help me fix my federated identity credential
Grant the Owner role and add CI/CD variables
The service principal needs permission to deploy resources at the tenant scope. It also needs its credentials stored as CI/CD variables so the pipeline can use them.
- Assign the Owner role at the tenant root scope by running this command:
az role assignment create --assignee [[AZURE_CLIENT_ID="your application client ID"]] --role Owner --scope "/"
Why Owner at the root scope?
Your Bicep templates create management groups and assign policies at the tenant level. The service principal needs Owner at the root scope (/) to perform these operations. This is the same scope you used when deploying manually in the prerequisite project.
You should see a JSON response showing the role assignment details.
- Retrieve your tenant ID by running:
az account show --query tenantId -otsv
- Record your tenant ID: your Azure tenant ID
Now add both values as CI/CD variables in your GitLab project so the pipeline can access them at runtime.
- Open your alz-governance project in GitLab.
- Click Settings in the left sidebar.
- Click CI/CD.
- Scroll to the Variables section and click Expand.
- Click Add variable.
- Enter AZURE_CLIENT_ID in the Key field.
- Paste your Application (client) ID (your application client ID) in the Value field.
- Uncheck Protect variable so it is available on all branches, not just protected ones.
- Click Add variable to save.
- Click Add variable again.
- Enter AZURE_TENANT_ID in the Key field.
- Paste your tenant ID (your Azure tenant ID) in the Value field.
- Uncheck Protect variable.
- Click Add variable to save.
You should now see both AZURE_CLIENT_ID and AZURE_TENANT_ID listed under Variables.
Why not mark these as protected?
Protected variables are only available to jobs running on protected branches (like main). Your validate stage needs to run on feature branches too, so both variables must be unprotected. These IDs are not secrets. They are identifiers, not credentials.
Now let's verify that GitLab can actually authenticate to Azure using the OIDC setup. You will create a minimal test pipeline that does nothing except log in and print your tenant details.
- Switch back to VS Code.
- Create a new file called .gitlab-ci.yml in the root of your alz-governance folder.
- Add the following test pipeline code:
default:
image: mcr.microsoft.com/azure-cli:latest
stages:
- auth
auth:
stage: auth
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
script:
- az login --service-principal -u $AZURE_CLIENT_ID -t $AZURE_TENANT_ID --federated-token $GITLAB_OIDC_TOKEN
- az account show
What does this test pipeline do?
The id_tokens block asks GitLab to generate a short-lived OIDC token for this job. The aud value (https://gitlab.com) must match the audience you set in the federated credential.
The az login command exchanges that token for Azure credentials. Then az account show prints your tenant details to prove the login succeeded.
- Save .gitlab-ci.yml.
- Push the test pipeline to GitLab by running these commands:
git add .gitlab-ci.yml
git commit -m "Add auth test pipeline"
git push
Before you check the pipeline, what do you think will happen? Will the auth job succeed or fail?
- Open your alz-governance project in GitLab.
- Click Build in the left sidebar, then click Pipelines.
- Click the pipeline that is running (or just finished).
- Click the auth job to open its log.
You should see az account show printing your tenant details, including your tenant ID. The job badge should show a green checkmark. GitLab authenticated to Azure without any stored secret.
Auth job failed?
If you see an error mentioning a mismatched federated identity record, check these three values:
- The issuer in body.json must be exactly https://gitlab.com (no trailing slash).
- The project_path in the expression must match your GitLab username and project name exactly (case-sensitive).
- The audiences value in the federated credential must match the aud value in .gitlab-ci.yml (both should be https://gitlab.com).
Help me debug my GitLab OIDC authentication to Azure
Authentication is wired up. You will replace this test pipeline with the real validation pipeline in the next step.
Build the Validation Pipeline
Your pipeline can now authenticate to Azure using OIDC, but authentication alone does not do anything useful. The pipeline has no job to run.
A what-if preview solves this. It shows you exactly what Azure will create, modify, or delete before any change goes live. In this step, you will write a .gitlab-ci.yml file that runs a what-if preview on every push, catching mistakes before they reach production.
In this step, get ready to:
- Write a .gitlab-ci.yml file with a validate stage.
- Push the pipeline to GitLab and trigger your first automated run.
- Read the what-if output to confirm your existing resources are unchanged.
Write the pipeline configuration
Every GitLab CI/CD pipeline starts with a .gitlab-ci.yml file in the root of your repository. This file tells GitLab what to run, in what order, and inside which environment.
- In VS Code, create a new file called .gitlab-ci.yml inside your alz-governance folder (the same folder that holds your Bicep files).
- Add the pipeline configuration by pasting this code into the file:
# Set the Docker image for all jobs
default:
image: mcr.microsoft.com/azure-cli:latest
# Define the pipeline stages
stages:
- validate
# Reference CI/CD variables and set the deployment region
variables:
AZURE_CLIENT_ID: "$AZURE_CLIENT_ID"
AZURE_TENANT_ID: "$AZURE_TENANT_ID"
DEPLOY_LOCATION: "eastus"
What does this configuration do?
The default block sets the Docker image every job runs inside. The mcr.microsoft.com/azure-cli:latest image includes both the Azure CLI and the Bicep CLI, so your pipeline can run az deployment commands without installing anything extra.
The variables block pulls in the AZURE_CLIENT_ID and AZURE_TENANT_ID values you stored as CI/CD variables in the previous step. It also sets DEPLOY_LOCATION to eastus so every deployment command uses the same region.
This configuration is the foundation. Next you will add the validate job that actually runs the what-if preview.
Add the validate job
- In .gitlab-ci.yml, add the validate job below the variables block by pasting this code at the bottom of the file:
validate:
stage: validate
# Request a short-lived OIDC token from GitLab
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
script:
# Authenticate to Azure using the OIDC token
- az login --service-principal -u $AZURE_CLIENT_ID -t $AZURE_TENANT_ID --federated-token $GITLAB_OIDC_TOKEN
# Preview management group changes
- az deployment tenant create --what-if --location $DEPLOY_LOCATION --template-file managementGroups.bicep
# Preview policy changes
- az deployment mg create --what-if --location $DEPLOY_LOCATION --management-group-id alz-landingzones --template-file policy.bicep
What does this job do?
The id_tokens block tells GitLab to generate a short-lived OIDC token for each run. The az login command uses that token to authenticate to Azure without any stored secret.
The two --what-if commands preview changes for each Bicep file separately. az deployment tenant create --what-if previews management group changes at the tenant scope. az deployment mg create --what-if previews policy changes at the alz-landingzones management group scope.
- Save .gitlab-ci.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Here is the complete .gitlab-ci.yml file. Make sure yours matches exactly:
default:
image: mcr.microsoft.com/azure-cli:latest
stages:
- validate
variables:
AZURE_CLIENT_ID: "$AZURE_CLIENT_ID"
AZURE_TENANT_ID: "$AZURE_TENANT_ID"
DEPLOY_LOCATION: "eastus"
validate:
stage: validate
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
script:
- az login --service-principal -u $AZURE_CLIENT_ID -t $AZURE_TENANT_ID --federated-token $GITLAB_OIDC_TOKEN
- az deployment tenant create --what-if --location $DEPLOY_LOCATION --template-file managementGroups.bicep
- az deployment mg create --what-if --location $DEPLOY_LOCATION --management-group-id alz-landingzones --template-file policy.bicep
Push the pipeline and trigger the first run
The pipeline file is ready. Pushing it to GitLab will trigger the pipeline automatically.
- Switch to your terminal. Make sure you are in the alz-governance folder.
- Stage, commit, and push the new file by running these commands:
git add .gitlab-ci.yml
git commit -m "Add validation pipeline"
git push
What happens when you push?
GitLab detects the .gitlab-ci.yml file in your repository and automatically starts the pipeline. A GitLab-hosted runner picks up the validate job, pulls the Azure CLI Docker image, and runs your script commands.
Before you check the output, think about what the what-if preview will show. Your management groups and policy are already deployed in Azure from the prerequisite project, and you have not changed the Bicep files. What do you expect the what-if to report?
- Open your GitLab project in your browser.
- In the left sidebar, select Build, then select Pipelines.
- You should see a pipeline running or recently completed. Select the pipeline to open its details page.
Pipeline stuck or not appearing?
GitLab.com may require identity verification (email, phone, or credit card) before shared runners can execute jobs. If your pipeline stays in a pending state, check your GitLab account settings for any pending verification prompts.
If the pipeline does not appear at all, confirm that .gitlab-ci.yml was pushed by running git log --oneline -1 in your terminal. You should see the commit message "Add validation pipeline".
Help me troubleshoot why my GitLab pipeline is not running.
Read the what-if output in the pipeline logs
- On the pipeline details page, select the validate job in the pipeline graph to open its log.
Scroll through the job log. You should see the az login command succeed, followed by the what-if output for both Bicep files.
Because your management groups and policy are already deployed and the Bicep files have not changed, the what-if preview reports no changes. You should see entries for your existing resources with a status indicating they already exist as defined.
What does the what-if output mean?
The what-if preview compares your Bicep template against the current state of your Azure tenant. When a resource already exists exactly as defined, the preview shows it with no planned modifications. If you had added a new management group or changed a policy, the preview would list those differences so you could review them before deploying.
- Confirm that the validate job shows a passed status.
Validate job failed?
If the job fails on the az login line, double check that AZURE_CLIENT_ID and AZURE_TENANT_ID are set correctly in your GitLab CI/CD variables. Also verify that the federated identity credential's subject claim matches your GitLab project path and branch.
If the job fails on one of the az deployment lines, confirm that the service principal still has the Owner role at the tenant root scope and that your management group hierarchy from the prerequisite project is still deployed.
Help me debug my failing GitLab CI/CD validate job.
Your pipeline now validates every push automatically. Next up, you will add a deploy stage that takes those validated changes and applies them live to your Azure tenant.
Add Deployment and Ship a Live Change
Your validation pipeline is running. Every push to GitLab triggers a what-if preview that shows exactly what Azure would create, modify, or delete.
The problem is that previewing is all the pipeline does. After validation passes, you still have to open your terminal and run deployment commands by hand. In this step, you will add a deploy stage that runs automatically when changes land on the main branch. Then you will ship a real infrastructure change through the pipeline to prove it works end to end.
In this step, get ready to:
- Add a deploy stage to .gitlab-ci.yml that only runs on the main branch.
- Add the Testing management group to managementGroups.bicep and push on a feature branch.
- Merge to main and verify the new management group appears in the Azure Portal.
Add a deploy stage to .gitlab-ci.yml
The validate stage proves a change is safe. A deploy stage takes the next step and actually applies that change to your Azure tenant. You only want deployments to happen from the main branch, so feature branches get previews but never push live changes.
- Create a feature branch for your changes by running this command in the alz-governance terminal:
git checkout -b add-deploy
- Open .gitlab-ci.yml in VS Code.
- Find the stages: section near the top of the file. It currently looks like this:
stages:
- validate
- Add - deploy below - validate so the section looks like this:
stages:
- validate
- deploy
- Scroll to the bottom of the file, below the entire validate job block. Add the following deploy job:
deploy:
stage: deploy
id_tokens:
GITLAB_OIDC_TOKEN:
aud: https://gitlab.com
script:
# Authenticate to Azure using OIDC
- az login --service-principal -u $AZURE_CLIENT_ID -t $AZURE_TENANT_ID --federated-token $GITLAB_OIDC_TOKEN
# Deploy management groups at tenant scope
- az deployment tenant create --name alz-mg-cicd --location $DEPLOY_LOCATION --template-file managementGroups.bicep
# Deploy policy at management group scope
- az deployment mg create --name alz-policy-cicd --location $DEPLOY_LOCATION --management-group-id alz-landingzones --template-file policy.bicep
rules:
# Only deploy when merging to main
- if: $CI_COMMIT_BRANCH == "main"
What does this deploy job do?
The deploy job mirrors the validate job's authentication and deployment commands, but with two key differences.
- It drops the --what-if flag. Instead of previewing changes, it applies them for real using az deployment tenant create and az deployment mg create.
- The rules block restricts it to run only when $CI_COMMIT_BRANCH == "main". On any other branch, GitLab CI/CD skips the deploy job entirely.
- Save .gitlab-ci.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved .gitlab-ci.yml before continuing.
ⓧ I'd like to double check the full code
Your .gitlab-ci.yml is now over 30 lines, so review each section in order:
- The default block sets the image to mcr.microsoft.com/azure-cli:latest.
- The stages list has validate and deploy in that order.
- The variables block defines AZURE_CLIENT_ID, AZURE_TENANT_ID, and DEPLOY_LOCATION.
- The validate job uses id_tokens, logs in via OIDC, and runs both --what-if commands.
- The deploy job uses id_tokens, logs in via OIDC, runs both deployment commands without --what-if, and has a rules block limiting it to the main branch.
Add the Testing management group and push on a feature branch
To prove the full pipeline works, you need an actual infrastructure change. You will add a new Testing management group to sit alongside Platform, Landing Zones, and Sandbox under your intermediate root.
- Open managementGroups.bicep in VS Code.
- Find one of the existing child management group resources (for example, the Sandbox management group). Copy the entire resource block for that management group and paste it below the last management group resource in the file.
- Update the pasted block so it defines the Testing management group. Change these three values:
- The Bicep resource symbolic name (the word after resource): change it to testing.
- The name property: change it to 'alz-testing'.
- The displayName property: change it to 'Testing'.
- Keep the parent reference the same as the other child management groups so Testing sits at the same level.
Why copy an existing block?
The existing management group resources already have the correct API version, parent reference, and structure. Copying one and changing three values is faster and less error-prone than writing a new resource block from scratch.
- Save managementGroups.bicep.
- Stage and commit both files by running these commands:
git add .gitlab-ci.yml managementGroups.bicep
git commit -m "Add deploy stage and Testing management group"
- Push the feature branch to GitLab by running:
git push -u origin add-deploy
Before you check the pipeline, think about what you expect to see. The push is on a feature branch, not main. Which stages do you think will run?
- Open your GitLab project in the browser.
- Click Build in the left sidebar, then click Pipelines.
- Click the most recent pipeline to open it.
You should see the validate job run. The deploy job should be skipped because you pushed to the add-deploy branch, not main. Click the validate job to open its log.
- In the job log, look for the what-if output for managementGroups.bicep. You should see a line showing the new alz-testing management group with a change type of Create.
What does the what-if output mean?
The what-if preview lists every resource Azure would create, modify, or delete if you ran the deployment for real. Seeing alz-testing with a Create change type confirms your Bicep change is correct. The existing management groups should show NoChange, proving nothing else is affected.
Validate job failed with an authentication error?
The OIDC federated identity credential you created in Step 2 may be scoped to the main branch only. The feature branch produces a different subject claim, so Azure rejects it.
You have two options: go back to Step 2 and add a second federated identity credential that matches the feature branch subject, or skip ahead to substep 3 and merge your changes to main first. The deploy job will still validate and deploy on main.
ask for help
Merge to main and verify the new management group in Azure
The what-if preview confirmed the Testing management group will be created and nothing else changes. Now it is time to merge to main and let the deploy stage apply the change to your Azure tenant.
- Switch back to the main branch by running:
git checkout main
- Merge the feature branch into main by running:
git merge add-deploy
Git performs a fast-forward merge because main has not changed since you branched off it.
- Push the updated main branch to GitLab by running:
git push origin main
Before you check the pipeline, what do you think will be different this time? The push is now on main. Which stages should run?
- Return to Build > Pipelines in your GitLab project.
- Click the latest pipeline. This time you should see both the validate job and the deploy job run.
- Click the deploy job and confirm it completes successfully. The log should show both az deployment tenant create and az deployment mg create finishing without errors.
Deploy job failed?
Check the job log for the specific error. Common causes:
- A typo in the rules block. Make sure it reads $CI_COMMIT_BRANCH == "main" with double equals and quotes around main.
- Missing id_tokens block in the deploy job. The deploy job needs its own OIDC token, even though the validate job already has one.
- The service principal does not have sufficient permissions. Confirm it has Owner role at the tenant root scope.
ask for help
Now verify the change landed in Azure.
- Open the Azure Portal in your browser.
- Search for Management groups in the top search bar and open it.
- Expand your intermediate root management group. You should see the new Testing management group listed alongside Platform, Landing Zones, and Sandbox.
That Testing management group was created entirely by the pipeline. You edited a Bicep file, pushed to Git, and the pipeline handled validation and deployment automatically.
Don't see the Testing management group?
Management group changes can take a few minutes to appear. Refresh the Azure Portal page and expand the hierarchy again.
If it still does not appear, go back to the deploy job log in GitLab and check for any error messages in the az deployment tenant create output.
ask for help
Secret mission
Add a Bicep Lint Stage
Your pipeline validates changes with what-if previews, but those previews still hit Azure APIs and take time. The Bicep linter catches syntax errors, unused variables, and best-practice violations locally, without ever contacting Azure. Can you add a lint stage that blocks bad code before it even reaches the what-if preview?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. All resources used in this project are free, so there are no ongoing costs.
Resources you used:
- Entra ID application and service principal (gitlab-oidc) with federated identity credentials.
- Owner role assignment for the service principal at the tenant root scope (/).
- Testing management group (alz-testing) in your Azure tenant hierarchy.
- GitLab.com project with CI/CD pipeline and stored CI/CD variables (AZURE_CLIENT_ID, AZURE_TENANT_ID).
- Local Git repository in ~/Desktop/alz-governance/.
Keep everything running
No action needed. All resources are free. Management groups, policies, and the Entra ID application incur no charges. Your GitLab.com Free tier compute minutes reset monthly.
Your pipeline will continue to validate and deploy changes whenever you push to the repository.
Pause - I'll come back to this later
No action needed. The pipeline only runs when you push changes, so it will not consume compute minutes while idle.
- All Azure resources (management groups, policies, Entra ID app, role assignment) remain in place at zero cost.
- When you are ready to resume, open VS Code, make a change to a Bicep file, and push. The pipeline picks up right where you left off.
Delete - I don't want to use this again
Remove all project resources and start fresh. Run these commands in your terminal to clean up your Azure tenant, then delete the GitLab project and local Git tracking.
- Remove the Testing management group by running:
az account management-group delete --name alz-testing
- Remove the service principal's Owner role assignment by running:
az role assignment delete --assignee [[AZURE_CLIENT_ID="your Entra ID application client ID"]] --scope "/" --role "Owner"
- Delete the Entra ID application (this also removes the service principal and federated identity credentials) by running:
az ad app delete --id [[AZURE_CLIENT_ID="your Entra ID application client ID"]]
- Delete the GitLab project. Open your project in GitLab.
- In the left sidebar, click Settings, then General.
- Expand the Advanced section.
- In the Delete project section, click Delete.
- Enter the project name in the confirmation dialog and click Yes, delete project.
What about the CI/CD variables?
The AZURE_CLIENT_ID and AZURE_TENANT_ID variables are stored inside the GitLab project. Deleting the project removes them automatically.
- Remove the local Git tracking from your project folder. This removes the .git directory but keeps your Bicep files intact.
macOS
rm -rf ~/Desktop/alz-governance/.git
Windows
Remove-Item -Recurse -Force $HOME\Desktop\alz-governance\.git
Nice Work!
Nice Work!
You just automated the deployment workflow for your Azure Landing Zone. Every infrastructure change now flows through version control, validation, and deployment without a single manual command.
You've learned how to:
- Push your Azure Landing Zone Bicep templates to a GitLab repository with full version history, turning local files into a trackable, collaborative codebase.
- Set up passwordless OIDC authentication between GitLab and Azure using federated identity credentials, eliminating stored secrets entirely.
- Build a two-stage CI/CD pipeline that runs what-if validation on every push and deploys infrastructure changes automatically when merged to main. You proved the full Infrastructure as Code lifecycle by shipping a live Testing management group through the pipeline.
- Secret Mission: Added a Bicep lint stage that catches syntax errors and best-practice violations before code reaches the what-if preview, blocking bad deployments at the earliest possible point.
Ready to quiz yourself?