Multi-Environment IaC Pipeline

Deploy Azure infrastructure across environments with Jenkins, Terragrunt, and GitLab.

Introduction

30 Second Summary

Every team that manages cloud infrastructure across multiple environments faces the same problem. You end up copying the same configuration files over and over, and a single deployment mistake in production can take down real services.

In this project, you will build a CI/CD pipeline in Jenkins that deploys Azure infrastructure using Terragrunt configurations stored in GitLab. The pipeline plans changes, waits for your manual approval, then applies them to the correct environment.

What You'll Build

You push a Terragrunt configuration change to GitLab, Jenkins plans both dev and prod environments in parallel, and after your approval click, real Azure resources appear in the portal.

By the end of this project, you'll have:

  • A working pipeline where you push code to GitLab, watch Jenkins plan both environments simultaneously, approve the deployment, and see Azure resource groups and storage accounts created in the portal.
  • A parameterized build dropdown that lets you choose dev, prod, or all from the Jenkins UI without editing a single file.
  • Hands-on experience with production pipeline patterns: credential binding, stash/unstash for plan artifacts, and manual approval gates that prevent unreviewed infrastructure changes.
  • Secret Mission: Extract your pipeline logic into a shared library hosted in GitLab so any infrastructure repo can reuse it with a single line of configuration.

Are there any prerequisites?

This project assumes you have experience with Terraform basics (providers, resources, modules), Jenkins pipeline syntax (stages, steps), and Git version control. You also need Docker Desktop installed on Windows and an Azure pay-as-you-go account.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Jenkins and GitLab

Jenkins is a powerful CI/CD server, but it does not ship with infrastructure tools like Terraform or Terragrunt. To run IaC pipelines, you need a custom Docker image that bundles everything together.

In this step, you will build a custom Jenkins Docker image with Terraform and Terragrunt baked in. You will also create a GitLab repository to host your IaC code and connect it to Jenkins.

In this step, get ready to:
  • Build a custom Jenkins Docker image with Terraform and Terragrunt installed.
  • Create a GitLab project and personal access token.
  • Complete the Jenkins setup wizard and store GitLab credentials.
Create the custom Jenkins Docker image

The official Jenkins image gives you a running CI server, but it has no idea how to talk to cloud providers. You will extend it with a Dockerfile that installs Terraform and Terragrunt at pinned versions.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir jenkins-terragrunt-build
cd jenkins-terragrunt-build
  • Create a file called Dockerfile in this folder by running:
touch Dockerfile
  • Confirm the file exists by running:
ls

You should see Dockerfile in the output.

  • Open Dockerfile in your text editor and paste the following content:
FROM jenkins/jenkins:2.568.1-jdk21
USER root
RUN apt-get update && apt-get install -y wget unzip curl gnupg
# Install Terraform 1.15.8
RUN wget https://releases.hashicorp.com/terraform/1.5.8/terraform_1.5.8_linux_amd64.zip \
  && unzip terraform_1.5.8_linux_amd64.zip \
  && mv terraform /usr/local/bin/ \
  && rm terraform_1.5.8_linux_amd64.zip
# Install Terragrunt v1.1.2
RUN wget https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.2/terragrunt_linux_amd64 \
  && chmod +x terragrunt_linux_amd64 \
  && mv terragrunt_linux_amd64 /usr/local/bin/terragrunt
USER jenkins

What does this Dockerfile do?

It starts from the official Jenkins LTS image (2.568.1-jdk21) which includes Java 21 and a ready-to-run Jenkins server.

  • USER root switches to the root user so you can install system packages.
  • The first RUN installs OS utilities needed to download and unpack binaries.
  • The second RUN downloads the Terraform binary and places it on the system path.
  • The third RUN downloads the Terragrunt binary and makes it executable.
  • USER jenkins drops back to the unprivileged Jenkins user for security.
  • Save Dockerfile.
  • Build the custom image by running this command:
docker build -t jenkins-terragrunt:2.568.1-1 .

Docker downloads the base image and runs each layer. You will see output for each step ending with a success message and the image ID.

Build failing or hanging?

  • Make sure Docker Desktop is running. If it is stopped, start it before retrying the build.
  • Check your internet connection. The build downloads several hundred megabytes.
  • Verify you are inside the jenkins-terragrunt-build folder where Dockerfile lives.

Help me debug my Docker build failure.

  • Run the container with a named volume for persistence:
docker run -d --name jenkins-terragrunt -p 8080:8080 -v jenkins-data:/var/jenkins_home jenkins-terragrunt:2.568.1-1

What do these flags mean?

  • -d runs the container in the background.
  • --name jenkins-terragrunt gives the container a memorable name.
  • -p 8080:8080 maps port 8080 on your machine to port 8080 inside the container so you can reach the Jenkins UI.
  • -v jenkins-data:/var/jenkins_home persists all Jenkins data to a named volume so nothing is lost when the container restarts.
  • Confirm the container is running:
docker ps

You should see a row listing jenkins-terragrunt with a status of Up and port 8080 mapped.

✔️ Awesome, I've got everything!

Great. Double check you have saved your Dockerfile and that the container is running.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile for comparison:

FROM jenkins/jenkins:2.568.1-jdk21
USER root
RUN apt-get update && apt-get install -y wget unzip curl gnupg
# Install Terraform 1.15.8
RUN wget https://releases.hashicorp.com/terraform/1.5.8/terraform_1.5.8_linux_amd64.zip \
  && unzip terraform_1.5.8_linux_amd64.zip \
  && mv terraform /usr/local/bin/ \
  && rm terraform_1.5.8_linux_amd64.zip
# Install Terragrunt v1.1.2
RUN wget https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.2/terragrunt_linux_amd64 \
  && chmod +x terragrunt_linux_amd64 \
  && mv terragrunt_linux_amd64 /usr/local/bin/terragrunt
USER jenkins
Sign up for GitLab and create your project

GitLab will host your Terragrunt configurations and, later, the shared pipeline library. The free tier gives you unlimited private repositories with no credit card required.

  • Open your browser and navigate to https://gitlab.com/users/sign_up.
  • Create a free account using your email or an existing Google/GitHub login.
  • Once logged in, click New project on the dashboard.
  • Select Create blank project.
  • Enter azure-infra-terragrunt as the Project name.
  • Set Visibility Level to Private.
  • Uncheck Initialize repository with a README.
  • Click Create project.

You should see the empty project page with instructions for pushing an existing repository.

Now generate a personal access token so Jenkins can pull code from this project.

  • In the upper-right corner, click your avatar.
  • Select Edit profile.
  • In the left sidebar, select Access then Personal access tokens.
  • From the Generate token dropdown, select Legacy token.
  • In Token name, enter jenkins-access.
  • Set an expiration date (30 days from today is fine for this project).
  • Under scopes, check read_repository and write_repository.
  • Click Generate token.

Copy your token now

GitLab shows the token value only once. Copy it immediately and paste it somewhere safe (a temporary text file or password manager). You will need it in the next substep.

Why read_repository and write_repository?

The read_repository scope lets Jenkins clone your IaC repo. The write_repository scope lets Jenkins push status or tags back if needed later. Together they give Git-over-HTTP access without exposing the full GitLab API.

Complete the Jenkins setup wizard and store GitLab credentials

Jenkins requires a one-time setup wizard the first time it starts. You will unlock it, install plugins, and then store your GitLab token so pipelines can pull code.

  • Retrieve the initial admin password by running:
docker exec jenkins-terragrunt cat /var/jenkins_home/secrets/initialAdminPassword

A long alphanumeric string prints to the terminal. Copy it.

  • Open your browser and go to http://localhost:8080.
  • Paste the password into the Administrator password field and click Continue.
  • On the Customize Jenkins page, click Install suggested plugins.

What about the Git plugin?

The Git plugin is included in the suggested plugins set. By choosing "Install suggested plugins" you automatically get Git support along with other commonly needed plugins.

Plugin installation takes a few minutes. You will see a progress screen as each plugin downloads and installs.

  • When the Create First Admin User page appears, fill in a username, password, and email of your choice.
  • Click Save and Finish.
  • Click Start using Jenkins.

You should see the Jenkins dashboard with a welcome message.

Now store your GitLab credentials in Jenkins so pipelines can authenticate.

  • Click Manage Jenkins in the left sidebar.
  • Click Credentials.
  • Under Stores scoped to Jenkins, click (global).
  • Click Add Credentials.
  • Set Kind to Username with password.
  • In Username, enter your GitLab username.
  • In Password, paste your GitLab personal access token.
  • In ID, enter gitlab-pat.
  • Click Create.

You should see gitlab-pat listed in the credentials table.

Don't see the Credentials option?

  • Make sure you clicked Manage Jenkins first. The Credentials link is under the Security section on that page.
  • If you skipped the suggested plugins, the Credentials plugin may not be installed. Go to Manage Jenkins then Plugins and install the Credentials and Credentials Binding plugins.

Help me find or configure credentials in Jenkins.

Time for the final check. Before you move on, predict what you will see when you ask the container for the Terragrunt version.

  • Verify Terragrunt is installed inside the container by running:
docker exec jenkins-terragrunt terragrunt --version

You should see v1.1.2 printed in the output. This confirms your custom image has Terragrunt available on the system path.

Your Jenkins server is running with IaC tools baked in, and GitLab is ready to host your configurations. Next up, you will create the Terragrunt directory structure that makes multi-environment deployments DRY.

Create Terraform Module and Terragrunt DRY Configs

In Step 1, you built a custom Jenkins image and connected it to GitLab. Now it is time to give the pipeline something to deploy.

Without Terragrunt, deploying Terraform to multiple environments means copying the same configuration files for dev, prod, staging, and every new environment. Terragrunt eliminates that duplication. You write one module and point each environment at it with different inputs.

In this step, get ready to:
  • Create a reusable Terraform module that provisions an Azure resource group and storage account.
  • Build DRY Terragrunt configurations for dev and prod environments sharing a single module.
  • Push the complete structure to GitLab and validate it inside the Jenkins container.
Create the reusable Terraform module

A Terraform module is a self-contained package of configuration that accepts inputs and produces outputs. You will create one module that knows how to provision a resource group and storage account in Azure. Both environments will reuse it.

  • Clone your empty GitLab project locally by running this command:
cd ~/Desktop
git clone https://gitlab.com/[[GITLAB_USER="your-gitlab-username"]]/azure-infra-terragrunt.git
cd azure-infra-terragrunt

What does this command do?

This clones the empty GitLab repo to your Desktop so you have a local copy to work in. When prompted for a password, enter the personal access token you generated in Step 1.

  • Create the module directory structure by running:
mkdir -p modules/storage environments/dev environments/prod
  • Confirm the directories exist by running:
ls -R

You should see modules/storage, environments/dev, and environments/prod in the output.

  • Create the module's variable definitions by saving the following as modules/storage/variables.tf:
variable "environment" {
  type        = string
  description = "Deployment environment (dev or prod)"
}

variable "location" {
  type        = string
  description = "Azure region"
  default     = "eastus"
}

variable "project_name" {
  type        = string
  description = "Project name prefix for resource naming"
}

What does this code do?

These three variables let the module be reused across environments. Each environment passes its own environment name and project_name. The location defaults to eastus but can be overridden.

  • Save modules/storage/variables.tf.
  • Confirm it exists by running:
ls modules/storage/

You should see variables.tf listed.

  • Create the main resource definitions by saving the following as modules/storage/main.tf:
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
  }
}

resource "azurerm_resource_group" "main" {
  name     = "${var.project_name}-${var.environment}-rg"
  location = var.location
}

resource "azurerm_storage_account" "main" {
  name                     = "${var.project_name}${var.environment}sa"
  resource_group_name      = azurerm_resource_group.main.name
  location                 = azurerm_resource_group.main.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
}

What does this code do?

This file declares the Azure provider requirement and defines two resources. The resource group uses the naming pattern projectname-environment-rg. The storage account concatenates the project name and environment without hyphens because Azure storage account names cannot contain special characters.

Both resources derive their names from variables, so the same module produces differently named infrastructure for dev vs prod.

  • Create the module's outputs by saving the following as modules/storage/outputs.tf:
output "resource_group_name" {
  value = azurerm_resource_group.main.name
}

output "storage_account_name" {
  value = azurerm_storage_account.main.name
}

What does this code do?

Outputs expose values from your module so other modules or your CI pipeline can reference them. Here you surface the resource group and storage account names for visibility in pipeline logs.

  • Save modules/storage/outputs.tf.
  • Confirm all three module files exist by running:
ls modules/storage/

You should see main.tf, outputs.tf, and variables.tf listed.

Missing a file?

Double check that you saved each file inside the modules/storage/ directory, not in the project root. If a file is in the wrong place, move it with mv filename modules/storage/.

help me find my missing Terraform module file

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these files is present in your modules/storage/ directory, matching the code blocks above:

  • variables.tf (3 variable blocks: environment, location, project_name).
  • main.tf (required_providers block, resource group, storage account).
  • outputs.tf (2 outputs: resource_group_name, storage_account_name).
Create the Terragrunt root and environment configs

Terragrunt's power comes from its include pattern. You define shared configuration (remote state, provider) once in a root terragrunt.hcl, and each environment inherits it. No duplication.

  • Create the root Terragrunt configuration by saving the following as terragrunt.hcl in the project root:
locals {
  project_name = "azinfra"
}

remote_state {
  backend = "azurerm"
  config = {
    resource_group_name  = "tfstate-rg"
    storage_account_name = "tfstateaccount"
    container_name       = "tfstate"
    key                  = "${path_relative_to_include()}/terraform.tfstate"
  }
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "azurerm" {
  features {}
}
EOF
}

What does this code do?

The remote_state block tells Terragrunt where to store state files. The path_relative_to_include() function automatically creates a unique state key per environment (e.g. environments/dev/terraform.tfstate).

The generate "provider" block auto-creates a provider.tf file in each environment directory so you never have to repeat provider configuration.

  • Save terragrunt.hcl in the project root.
  • Create the dev environment config by saving the following as environments/dev/terragrunt.hcl:
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "../../modules/storage"
}

inputs = {
  environment  = "dev"
  location     = "eastus"
  project_name = "azinfra"
}

What does this code do?

The include block pulls in everything from the root terragrunt.hcl (remote state and provider). The terraform.source points to your reusable module. The inputs block passes dev-specific values to the module's variables.

  • Verify the directory structure looks correct by running:
ls environments/dev/

You should see terragrunt.hcl listed.

  • Create the prod environment config by saving the following as environments/prod/terragrunt.hcl:
include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "../../modules/storage"
}

inputs = {
  environment  = "prod"
  location     = "eastus"
  project_name = "azinfra"
}

What does this code do?

This is nearly identical to the dev config. The only difference is environment = "prod". That single value change means the module will create resources named azinfra-prod-rg instead of azinfra-dev-rg. This is the DRY pattern in action.

Why Terragrunt over copy-paste

Without Terragrunt, adding a staging environment would mean copying all of dev's Terraform files and changing values in multiple places. With Terragrunt, you create one 13-line file. The module, backend, and provider stay unchanged.

  • Save environments/prod/terragrunt.hcl.
  • Confirm both environment configs exist by running:
ls environments/dev/ environments/prod/

You should see terragrunt.hcl listed under both directories.

Don't see terragrunt.hcl in both?

Make sure you saved the prod file to environments/prod/terragrunt.hcl, not environments/dev/terragrunt.hcl (overwriting dev). Check the environment input value inside each file to confirm which is which.

help me fix my Terragrunt environment configs

✔️ Awesome, I've got everything!

Great. Double check you have saved all Terragrunt files.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these files is present, matching the code blocks above:

  • terragrunt.hcl in the project root (locals, remote_state, generate provider).
  • environments/dev/terragrunt.hcl (include, source, inputs with environment = "dev").
  • environments/prod/terragrunt.hcl (include, source, inputs with environment = "prod").
Push to GitLab and validate inside Jenkins

Your files are ready locally. Now push them to GitLab so the Jenkins container can pull them down and validate the full configuration.

  • Stage and push all files to GitLab by running these commands:
git add .
git commit -m "Add Terraform module and Terragrunt configs"
git push origin main

What does this do?

This commits your entire directory structure to the main branch and pushes it to your GitLab remote. Git will prompt for credentials. Enter your GitLab username and personal access token as the password.

Push rejected or authentication error?

If you see an authentication failure, confirm you are entering your personal access token (not your GitLab password) when prompted. The token must have write_repository scope.

If the push is rejected because the branch does not exist, try git push -u origin main to set the upstream.

help me push my code to GitLab

  • Now clone the repo inside your Jenkins container and validate it by running:
docker exec -it jenkins-terragrunt bash
  • Inside the container, clone your repo and navigate into it:
cd /tmp
git clone https://gitlab.com/[[GITLAB_USER="your-gitlab-username"]]/azure-infra-terragrunt.git
cd azure-infra-terragrunt

Before you run the validation, what do you expect to happen? Both dev and prod configs reference the same module. If the HCL syntax is correct, Terraform should report both as valid.

  • Validate all configurations by running:
terragrunt run --all -- validate

You should see output confirming that both the dev and prod configurations validated successfully. Terragrunt discovers both environment directories and runs terraform validate in each one.

Seeing validation errors?

If you see a syntax error, check that every block in your .hcl files has matching braces. A common mistake is a missing closing } in the remote_state block.

If Terragrunt says it cannot find terragrunt.hcl, make sure you are running the command from the project root (/tmp/azure-infra-terragrunt), not from inside an environment directory.

help me fix Terragrunt validation errors

  • Exit the Jenkins container shell by running:
exit

Your infrastructure code is structured, pushed, and validated. Next up, you will write a Jenkinsfile that runs terragrunt plan and stashes the output for a safe, reviewable deployment.

Write the Jenkinsfile with Plan and Stash

A naive pipeline would plan and apply infrastructure in one shot with no human review. That means whatever Terraform calculates is immediately applied, and nobody gets a chance to catch mistakes.

The stash/unstash pattern solves this by saving the exact plan output as a build artifact. What you reviewed is exactly what gets applied later. In this step, you will write a declarative Jenkinsfile that plans your infrastructure and stashes the result, then create a pipeline job in Jenkins to run it.

In this step, get ready to:
  • Write a declarative Jenkinsfile with credential bindings, init, plan, and stash stages.
  • Create a Pipeline job in Jenkins that reads the Jenkinsfile from GitLab.
  • Trigger the pipeline and observe the authentication failure (designed obstacle).
Write the declarative Jenkinsfile

Jenkins needs a file called Jenkinsfile in the root of your repository to know how to run the pipeline. You will use Jenkins' credentials() helper in the environment block to inject Azure secrets as environment variables at runtime.

  • In your local azure-infra-terragrunt repository, create a new file called Jenkinsfile in the project root.
  • Add the pipeline skeleton with the credential environment block and a Checkout stage by pasting the following code:
pipeline {
  agent any

  environment {
    // Inject Azure credentials from Jenkins credential store
    ARM_CLIENT_ID       = credentials('ARM_CLIENT_ID')
    ARM_CLIENT_SECRET   = credentials('ARM_CLIENT_SECRET')
    ARM_TENANT_ID       = credentials('ARM_TENANT_ID')
    ARM_SUBSCRIPTION_ID = credentials('ARM_SUBSCRIPTION_ID')
  }

  stages {
    stage('Checkout') {
      steps {
        checkout scm
      }
    }
  }
}

What does this code do?

The environment block uses Jenkins' credentials() helper to look up Secret Text credentials by their ID. At runtime, Jenkins injects the secret values into environment variables that the Azure provider reads automatically.

The Checkout stage runs checkout scm, which clones the exact revision that triggered this pipeline from your GitLab repository.

  • Now add the Init and Plan stages after the Checkout stage, inside the stages block. Find the closing } of the Checkout stage and paste the following code below it:
    stage('Init') {
      steps {
        // Initialize all Terragrunt environments
        sh 'terragrunt run --all -- init -input=false'
      }
    }

    stage('Plan') {
      steps {
        // Run plan and capture output to a text file
        sh 'terragrunt run --all -- plan -out=tfplan 2>&1 | tee plan-output.txt'
        // Stash plan files for use in a later Apply stage
        stash name: 'tfplan', includes: '**/tfplan,plan-output.txt'
        // Archive the plan text so you can review it from the build page
        archiveArtifacts artifacts: 'plan-output.txt', fingerprint: true
      }
    }

What does the Plan stage do?

The terragrunt run --all -- plan -out=tfplan command runs Terragrunt plan across all environments and saves the binary plan to tfplan. The tee command pipes the human-readable output to plan-output.txt at the same time.

The stash step saves matching files into Jenkins' internal storage, making them available to later stages (even on a different agent). The archiveArtifacts step publishes plan-output.txt to the build page so you can review it from the Jenkins UI.

Why stash instead of just re-running plan?

If you re-ran terraform plan at apply time, the state could have changed between plan and apply. Stashing the binary plan file guarantees that what you approved is exactly what gets applied. This is a critical safety pattern for production pipelines.

  • Save Jenkinsfile.
  • Push the file to your GitLab repository by running these commands:
git add Jenkinsfile
git commit -m "Add Jenkinsfile with plan and stash stages"
git push
  • Confirm the push succeeded by checking that git push prints a line containing your azure-infra-terragrunt project URL.

Push rejected or authentication error?

Make sure you are using the GitLab personal access token you created in Step 1 as your password when prompted. The token must have write_repository scope.

If you see a branch protection error, confirm you are pushing to main (or the default branch) and that no protection rules block pushes. Help me fix my git push to GitLab

✔️ Awesome, I've got everything!

Great. Double check you have saved Jenkinsfile and that it is committed and pushed to GitLab.

ⓧ I'd like to double check the full code

Your Jenkinsfile should contain these sections in order. Scroll up and confirm each one is present:

  • The pipeline wrapper with agent any.
  • The environment block with four credentials() calls for ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, and ARM_SUBSCRIPTION_ID.
  • The Checkout stage with checkout scm.
  • The Init stage running terragrunt run --all -- init -input=false.
  • The Plan stage running the plan command, stash, and archiveArtifacts.
Create and run the pipeline job in Jenkins

With the Jenkinsfile pushed to GitLab, you can now point Jenkins at your repository and let it discover the pipeline definition automatically.

  • Open your Jenkins dashboard in the browser (http://localhost:8080).
  • Click New Item in the left sidebar.
  • Enter azure-infra-terragrunt in the Enter an item name field.
  • Select Pipeline from the list of job types, then click OK.
  • Scroll down to the Pipeline section at the bottom of the configuration page.
  • Change the Definition dropdown from Pipeline script to Pipeline script from SCM.
  • Select Git from the SCM dropdown.
  • Paste your GitLab repository HTTPS URL into the Repository URL field.
  • In the Credentials dropdown below the URL, select the GitLab credentials you configured in Step 1 (username + PAT).
  • Leave the Script Path as the default value Jenkinsfile.
  • Click Save.

What is Pipeline script from SCM?

This option tells Jenkins to fetch the Jenkinsfile directly from your Git repository instead of pasting pipeline code into the Jenkins UI. Every time you push a change to the Jenkinsfile, Jenkins picks up the latest version automatically.

Before you trigger the build, think about what will happen. The Jenkinsfile references four credential IDs (ARM_CLIENT_ID, ARM_CLIENT_SECRET, etc.) that do not exist in Jenkins yet. Do you think the pipeline will succeed or fail?

  • Click Build Now in the left sidebar of your new pipeline job.
  • Click the build number (#1) that appears under Build History, then click Console Output.

The build fails. In the console output you will see an error indicating that the credential IDs cannot be found. This is expected. The pipeline references Azure credentials that you have not created in Jenkins yet.

Seeing this failure? Good.

This failure is intentional. Your Jenkinsfile is correctly written, but Jenkins has no Azure Service Principal credentials stored under the IDs ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, or ARM_SUBSCRIPTION_ID. You will fix this in the next step by creating an Azure Service Principal and storing the values in Jenkins.

Your Jenkinsfile is committed, your pipeline job is wired up, and you have seen exactly where it breaks. Next up, you will create the Azure Service Principal, store its values in Jenkins, and add a manual approval gate so no infrastructure deploys without your sign-off.

Fix Authentication and Add Approval Gate

In the previous step, your pipeline failed at the Plan stage with an authentication error. That failure is expected and intentional.

The Jenkinsfile references four credential IDs (ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID) that do not exist in Jenkins yet. In this step, you will create an Azure Service Principal, store its values in Jenkins, and add an approval gate so infrastructure never deploys without human review.

In this step, get ready to:
  • Create an Azure Service Principal and store its credentials in Jenkins.
  • Add Approval and Apply stages to the Jenkinsfile.
  • Run the pipeline end-to-end and confirm infrastructure exists in Azure.
Create an Azure Service Principal and store credentials in Jenkins

Jenkins needs a Service Principal to authenticate with Azure on your behalf. A Service Principal is a non-interactive identity with scoped permissions.

  • Open a terminal on your host machine (outside the Docker container) and create a Service Principal by running this command:
az ad sp create-for-rbac --name "jenkins-terragrunt" --role Contributor --scopes /subscriptions/[[SUBSCRIPTION_ID="your-azure-subscription-id"]]

What does this command do?

This creates an Azure Active Directory application named jenkins-terragrunt and assigns it the Contributor role scoped to your entire subscription. Contributor can create and manage resources but cannot grant access to others.

The output includes four values you need: appId (your client ID), password (your client secret), tenant (your tenant ID), and your subscription ID (you already know this from the command).

The output looks similar to this (your values will differ):

{
  "appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "displayName": "jenkins-terragrunt",
  "password": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "tenant": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}
  • Copy the appId, password, and tenant values from the output. Keep them somewhere safe for the next few minutes.

Find your subscription ID

If you do not have your subscription ID handy, run az account show --query id -o tsv to retrieve it.

Now you will store these four values as Secret Text credentials in Jenkins so the pipeline can inject them at runtime.

  • Switch back to your Jenkins dashboard in the browser.
  • Click Manage Jenkins in the left sidebar.
  • Click Credentials.
  • Under Stores scoped to Jenkins, click System.
  • Click Global credentials (unrestricted).
  • Click Add Credentials.

You will add the first credential now, then repeat for the other three.

  • In the Kind dropdown, select Secret text.
  • Leave Scope set to Global.
  • Paste the appId value from your Service Principal output into the Secret field.
  • In the ID field, type ARM_CLIENT_ID.
  • Click OK.

Repeat the same steps three more times for the remaining credentials:

  • Secret: your password value. ID: ARM_CLIENT_SECRET.
  • Secret: your tenant value. ID: ARM_TENANT_ID.
  • Secret: your subscription ID. ID: ARM_SUBSCRIPTION_ID.
  • After adding all four, navigate back to Global credentials (unrestricted). You should see all four credentials listed: ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, and ARM_SUBSCRIPTION_ID.

Don't see all four credentials?

Make sure you clicked OK (not Cancel) after each credential. If one is missing, click Add Credentials again and re-enter it.

Double-check that each ID matches exactly: ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID. A typo here causes the pipeline to fail with a credential-not-found error.

Help me troubleshoot missing Jenkins credentials for my Azure pipeline.

Add Approval and Apply stages to the Jenkinsfile

With credentials in place, Jenkins can authenticate to Azure. But a production pipeline should never apply infrastructure changes automatically. You need a human approval gate between planning and applying.

  • Open the Jenkinsfile in your local azure-infra-terragrunt repository.
  • Find the closing brace of the Plan stage (the } that closes its stage('Plan') block).
  • Add the following two stages immediately after the Plan stage, inside the stages block:
    stage('Approve') {
      steps {
        // Pause the pipeline and wait for a human to click Deploy
        input message: 'Review the plan above. Approve to apply?', ok: 'Deploy'
      }
    }

    stage('Apply') {
      steps {
        // Retrieve the exact plan files that were reviewed
        unstash 'tfplan'
        // Apply the pre-computed plan to create infrastructure
        sh 'terragrunt run --all -- apply tfplan'
      }
    }

What do these stages do?

The Approve stage uses Jenkins' input step to pause the pipeline. No further stages run until someone clicks the Deploy button in the Jenkins UI. This ensures a human reviews the plan before any changes reach Azure.

The Apply stage calls unstash 'tfplan' to restore the exact plan artifact from the Plan stage. This guarantees what you reviewed is exactly what gets applied. No drift can sneak in between planning and applying.

  • Save the Jenkinsfile.
  • Commit and push the updated Jenkinsfile to GitLab by running these commands:
git add Jenkinsfile
git commit -m "Add approval gate and apply stage"
git push origin main

Push rejected or authentication error?

Make sure you are using your GitLab personal access token as the password when prompted. If you set up credential caching in a previous step, Git should remember it automatically.

Help me fix a git push authentication error to GitLab.

✔️ Awesome, I've got everything!

Great. Double-check you have saved and pushed the file.

ⓧ I'd like to double check the full code

Here is the complete Jenkinsfile after adding the Approve and Apply stages:

pipeline {
  agent any

  environment {
    ARM_CLIENT_ID       = credentials('ARM_CLIENT_ID')
    ARM_CLIENT_SECRET   = credentials('ARM_CLIENT_SECRET')
    ARM_TENANT_ID       = credentials('ARM_TENANT_ID')
    ARM_SUBSCRIPTION_ID = credentials('ARM_SUBSCRIPTION_ID')
  }

  stages {
    stage('Checkout') {
      steps {
        checkout scm
      }
    }
    stage('Init') {
      steps {
        sh 'terragrunt run --all -- init -input=false'
      }
    }

    stage('Plan') {
      steps {
        sh 'terragrunt run --all -- plan -out=tfplan 2>&1 | tee plan-output.txt'
        stash name: 'tfplan', includes: '**/tfplan,plan-output.txt'
        archiveArtifacts artifacts: 'plan-output.txt', fingerprint: true
      }
    }

    stage('Approve') {
      steps {
        input message: 'Review the plan above. Approve to apply?', ok: 'Deploy'
      }
    }

    stage('Apply') {
      steps {
        unstash 'tfplan'
        sh 'terragrunt run --all -- apply tfplan'
      }
    }
  }
}
Run the pipeline end-to-end and verify in Azure

Everything is in place: credentials stored, approval gate added, apply stage ready. Time to run the pipeline for real.

  • Switch back to your Jenkins dashboard.
  • Click on your azure-infra-terragrunt pipeline job.
  • Click Build Now in the left sidebar.

Before you check the console output, what do you expect to happen this time? The credentials exist now, so the Init and Plan stages should succeed. The pipeline should then pause at the Approve stage waiting for your click.

  • Click on the running build number (e.g. #2) in the build history.
  • Click Console Output to watch the stages execute.

You should see the Init stage download providers, and the Plan stage output lines showing Azure resources to be created (resource groups and storage accounts for dev and prod). The pipeline then pauses with a prompt asking you to approve.

  • In the console output, you will see a Review the plan above. Approve to apply? prompt with a Deploy button. Click Deploy.

After you click Deploy, the Apply stage runs terragrunt run --all -- apply tfplan and creates the infrastructure in Azure. The console output should end with a success message.

  • Open the Azure Portal in your browser.
  • Navigate to Resource groups.
  • Confirm that azinfra-dev-rg and azinfra-prod-rg both exist in the eastus region.
  • Click into azinfra-dev-rg and confirm a storage account named azinfradevsa exists.

Why stash and unstash?

Without stash/unstash, you would have to re-run terraform plan in the Apply stage. Between the Plan and Apply stages, someone else could merge a change, causing drift between what you reviewed and what actually applies.

By stashing the binary plan file immediately after planning, and unstashing it in Apply, you guarantee that the exact changeset you approved is the one that runs. This is a critical safety pattern for production pipelines.

Pipeline still failing at Init or Plan?

If you see an error about missing credentials, double-check that each credential ID in Jenkins matches exactly: ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID. These are case-sensitive.

If the error mentions the Service Principal lacking permissions, verify that the --scopes flag in your az ad sp create-for-rbac command used your full subscription path starting with /subscriptions/.

Help me debug my Jenkins Terragrunt pipeline Azure authentication error.

Your pipeline now deploys real Azure infrastructure with a human approval gate in the middle. Next up, you will add parameterized builds and parallel stages so the pipeline can target specific environments.

Add Parameterized Builds and Parallel Plans

Your pipeline runs end-to-end with a plan, approval gate, and apply. But right now it always plans and applies both environments together.

Real infrastructure teams need to deploy to dev without touching prod, or plan both environments simultaneously to save time. In this step, you will add a parameters block that lets you choose which environment to target, and rewrite the Plan and Apply stages to run in parallel.

In this step, get ready to:
  • Add a choice parameter that lets operators select dev, prod, or all environments.
  • Rewrite Plan and Apply stages with parallel branches gated by when expressions.
  • Test the pipeline with both "all" and "dev" parameter values.
Add the parameters block and parallel Plan stages

Jenkins Declarative Pipeline supports a parameters directive that prompts the user to pick a value before the build starts. The first value in the choices list becomes the default.

  • Open your Jenkinsfile in your editor.
  • Find the agent any line at the top of the pipeline block.
  • Add the following parameters block directly below agent any and above the environment block:
  parameters {
    choice(
      name: 'ENVIRONMENT',
      choices: ['all', 'dev', 'prod'],
      description: 'Select environment to deploy'
    )
  }

What does this code do?

The choice parameter creates a dropdown in the Jenkins UI. When someone triggers a build, they pick all, dev, or prod.

The value is accessible in pipeline code as params.ENVIRONMENT. Since all is first in the list, it becomes the default selection.

Now replace the entire existing stage('Plan') block (the one with terragrunt run --all -- plan) with two parallel branches that plan each environment independently. Copy the following code in its place:

    stage('Plan') {
      parallel {
        stage('Plan Dev') {
          when {
            expression { params.ENVIRONMENT == 'dev' || params.ENVIRONMENT == 'all' }
          }
          steps {
            dir('environments/dev') {
              sh 'terragrunt plan -out=tfplan 2>&1 | tee ../../plan-dev.txt'
            }
            stash name: 'tfplan-dev', includes: 'environments/dev/tfplan,plan-dev.txt'
            archiveArtifacts artifacts: 'plan-dev.txt', fingerprint: true
          }
        }
        stage('Plan Prod') {
          when {
            expression { params.ENVIRONMENT == 'prod' || params.ENVIRONMENT == 'all' }
          }
          steps {
            dir('environments/prod') {
              sh 'terragrunt plan -out=tfplan 2>&1 | tee ../../plan-prod.txt'
            }
            stash name: 'tfplan-prod', includes: 'environments/prod/tfplan,plan-prod.txt'
            archiveArtifacts artifacts: 'plan-prod.txt', fingerprint: true
          }
        }
      }
    }

What does this code do?

The parallel block tells Jenkins to run Plan Dev and Plan Prod at the same time instead of sequentially. Each branch has a when expression that checks the ENVIRONMENT parameter value.

The dir('environments/dev') block changes into the specific environment folder so Terragrunt runs against that single configuration. Each branch stashes its own plan file with a unique name (tfplan-dev and tfplan-prod) for later retrieval.

  • Save the Jenkinsfile so you can confirm the parallel Plan stage parses correctly before continuing.
Update the Apply stage with parallel branches

The Apply stage needs the same parallel treatment. It must unstash and apply only the environments that were planned.

  • Find the existing stage('Apply') block (the one with unstash 'tfplan' and terragrunt run --all -- apply tfplan).
  • Replace that entire stage with the following parallel version:
    stage('Apply') {
      parallel {
        stage('Apply Dev') {
          when {
            expression { params.ENVIRONMENT == 'dev' || params.ENVIRONMENT == 'all' }
          }
          steps {
            unstash 'tfplan-dev'
            dir('environments/dev') {
              sh 'terragrunt apply tfplan'
            }
          }
        }
        stage('Apply Prod') {
          when {
            expression { params.ENVIRONMENT == 'prod' || params.ENVIRONMENT == 'all' }
          }
          steps {
            unstash 'tfplan-prod'
            dir('environments/prod') {
              sh 'terragrunt apply tfplan'
            }
          }
        }
      }
    }

What does this code do?

Each Apply branch unstashes its matching plan (tfplan-dev or tfplan-prod) and applies it within the correct environment directory. The when expressions mirror the Plan stage, so only the environments that were planned will be applied.

This ensures what you reviewed is exactly what gets deployed. No environment mismatch is possible.

  • Save the Jenkinsfile.

Seeing a syntax error?

Double-check that your stage('Plan') block now contains parallel { ... } directly (not steps). A stage must have exactly one of steps, stages, parallel, or matrix. If you accidentally left the old steps block alongside parallel, Jenkins will reject the pipeline.

help me fix a Jenkins parallel stage syntax error in my Jenkinsfile

✔️ Awesome, I've got everything!

Great. Double check you have saved your Jenkinsfile.

ⓧ I'd like to double check the full code

Your Jenkinsfile should contain these sections in order. Scroll up and confirm each is present:

  • The parameters block with the ENVIRONMENT choice (after agent any).
  • The environment block with the four ARM credentials (unchanged from before).
  • Stages: Checkout, Init, Plan (with parallel containing Plan Dev and Plan Prod), Approve, Apply (with parallel containing Apply Dev and Apply Prod).
Push and run the pipeline with "all"
  • Commit and push your updated Jenkinsfile to GitLab by running these commands:
git add Jenkinsfile
git commit -m "Add parameterized builds and parallel plan/apply stages"
git push origin main
  • Return to the Jenkins dashboard in your browser.
  • Open your pipeline job.
  • Click Build with Parameters in the left sidebar.

Why does it say Build with Parameters now?

Adding a parameters block to your Jenkinsfile changes the build trigger from Build Now to Build with Parameters. Jenkins detects the parameter definition after the first run and shows the dropdown from then on.

If you still see Build Now, trigger one build to let Jenkins parse the new Jenkinsfile. After that first run, the Build with Parameters option appears.

  • Select all from the ENVIRONMENT dropdown.
  • Click Build.

Before you look at the results, what do you expect to see in the Stage View? Do you think the Plan stages will appear side-by-side or stacked vertically?

  • Open the build and check the Stage View (or Pipeline Steps).

You should see Plan Dev and Plan Prod running in parallel as side-by-side columns. Both plan stages complete, the Approve stage pauses for your click, and after approval both Apply stages run in parallel.

  • Click Deploy when prompted at the Approve stage to let the pipeline finish.
Confirm selective deployment with "dev"
  • Click Build with Parameters again.
  • Select dev from the ENVIRONMENT dropdown.
  • Click Build.

What do you expect to happen to the Plan Prod and Apply Prod stages this time?

  • Open the build's Stage View.

You should see only Plan Dev execute. The Plan Prod stage is skipped because the when expression evaluates to false. After approval, only Apply Dev runs.

  • Click Deploy at the Approve stage to complete the build.

Why is this useful?

In a real team, you would deploy to dev first to validate changes, then trigger a separate prod deployment after verifying dev is healthy. The parameter lets operators control this without editing the Jenkinsfile each time.

Prod stage still running when you selected dev?

Check that your when expression uses params.ENVIRONMENT (not env.ENVIRONMENT or ENVIRONMENT without the params. prefix). Pipeline parameters are accessed through the params object, not the env object.

help me debug why my Jenkins when expression is not skipping a stage based on the ENVIRONMENT parameter

Your pipeline now supports targeted deployments. Operators can plan and apply a single environment or run everything in parallel. Next up, you will extract this pipeline logic into a shared library so any infrastructure repo can reuse it with a single line of configuration.

Secret mission

Extract Pipeline Logic into a Shared Library

Copying Jenkinsfiles across repos does not scale. In organizations with dozens of infrastructure repositories, every team duplicates the same plan/approve/apply pipeline logic. Can you extract your pipeline into a GitLab-hosted shared library so any repo can consume it with a single line of configuration?

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. Azure storage accounts cost fractions of a cent per day, so leaving resources running briefly carries minimal cost.

Cost warning

The Azure resource groups and storage accounts you created cost pennies per day. If you leave them running for a week, you will spend less than a dollar. However, if you are done experimenting, delete them to avoid any charges.

Resources you used:

  • Azure resource groups (azinfra-dev-rg and azinfra-prod-rg) with storage accounts.
  • Docker container (jenkins-terragrunt) with a named volume (jenkins-data).
  • GitLab projects (azure-infra-terragrunt, jenkins-shared-library, and azure-infra-terragrunt-2).

Keep everything running

No action needed. Choose this if you are still actively building or experimenting with additional Terragrunt configurations and Jenkins pipeline patterns.

  • Your Jenkins container continues running and is ready for new pipeline jobs.
  • Azure storage accounts remain available at fractions of a cent per day.
  • GitLab projects are free-tier and have no ongoing cost.

Pause - I'll come back to this later

Shut down running processes to free up system resources while keeping everything intact for later.

  • Stop the Jenkins container by running:
  • Azure resources remain in place. Storage costs are negligible (fractions of a cent per day).
  • GitLab projects remain as-is with no cost.
  • To resume later, start the container again with docker start jenkins-terragrunt.
docker stop jenkins-terragrunt

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each group of instructions below.

Destroy Azure infrastructure:

  • Run Terragrunt destroy from inside the Jenkins container to remove all Azure resources:
docker exec jenkins-terragrunt bash -c "cd /var/jenkins_home/workspace && terragrunt run --all -- destroy -auto-approve"

If the workspace path differs

Your workspace path may differ based on your Jenkins job name. If the command fails, exec into the container with docker exec -it jenkins-terragrunt bash, navigate to your project workspace, and run terragrunt run --all -- destroy from there.

  • Confirm the resource groups are gone by running:
az group list --query "[?starts_with(name, 'azinfra')]" --output table

You should see an empty table confirming both resource groups have been deleted.

Remove Docker resources:

  • Stop and remove the Jenkins container, then delete the named volume:
docker stop jenkins-terragrunt
docker rm jenkins-terragrunt
docker volume rm jenkins-data

Delete GitLab projects:

  • For each of your three GitLab projects, open the project in your browser.
  • Select Settings in the left sidebar, then select General.
  • Expand the Advanced section.
  • In the Delete project section, select Delete.
  • Enter the project name in the confirmation dialog and select Yes, delete project.
  • Repeat for azure-infra-terragrunt, jenkins-shared-library, and azure-infra-terragrunt-2.

Nice Work!

Nice Work!

You just built a production-style IaC pipeline that deploys Azure infrastructure across multiple environments using Jenkins, Terragrunt, and GitLab.

Here's what you accomplished:

  • Built a custom Jenkins Docker image with Terraform 1.15.8 and Terragrunt v1.1.2 baked in, and created DRY Terragrunt configurations that deploy to multiple Azure environments from a single reusable module.
  • Implemented intermediate Jenkins pipeline patterns: credential binding with Secret Text, stash/unstash for plan artifacts, input steps for manual approval, and parameterized builds with parallel stage execution for multi-environment promotion.
  • Extracted pipeline logic into a GitLab-hosted shared pipeline library, proving this pattern scales across repositories.
  • Secret Mission: Built a shared library that any infrastructure repo can consume with a single @Library annotation, eliminating duplicated pipeline code across projects.

Ready to quiz yourself?