7.2. Azure Credentials for CI/CD

Terraform needs an Azure service principal to authenticate from a pipeline runner — a non-interactive identity with scoped permissions. In this lab you create the service principal with the Azure CLI and store the resulting credentials as masked GitLab CI/CD variables.

Preparation

Make sure you are logged in to the Azure CLI and have the correct subscription selected:

az login
az account show

If you need to switch subscription:

az account set --subscription "<your-subscription-id>"

Step 7.2.1: Create a service principal

Create a service principal scoped to your subscription with the Contributor role:

az ad sp create-for-rbac \
  --name "sp-gitlab-pipeline-<your-username>" \
  --role Contributor \
  --scopes /subscriptions/<your-subscription-id>

The command returns JSON — keep this output, you will need the values in the next step:

{
  "appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "displayName": "sp-gitlab-pipeline-<your-username>",
  "password": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "tenant": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
}

You also need your subscription ID:

az account show --query id -o tsv

Explanation

create-for-rbac creates an Azure Active Directory application and a service principal in one step, then assigns the specified RBAC role. The Contributor role allows creating and managing all resource types but not assigning roles — a safe default for Terraform pipelines. For production workloads, scope the principal to a specific resource group instead of the whole subscription.

Step 7.2.2: Store credentials as GitLab CI/CD variables

In your GitLab project navigate to Settings → CI/CD → Variables and add the following four variables. Use the values from the JSON output above.

VariableJSON fieldMaskedProtected
ARM_CLIENT_IDappIdyesyes
ARM_CLIENT_SECRETpasswordyesyes
ARM_SUBSCRIPTION_IDoutput of az account showyesyes
ARM_TENANT_IDtenantyesyes

For each variable:

  1. Click Add variable
  2. Set the Key and Value
  3. Enable Mask variable — this redacts the value in all job logs
  4. Enable Protect variable — this restricts the variable to protected branches and tags only
  5. Click Add variable

Step 7.2.3: Verify the credentials

To quickly verify the service principal works before running a full pipeline, you can test it locally:

export ARM_CLIENT_ID="<appId>"
export ARM_CLIENT_SECRET="<password>"
export ARM_TENANT_ID="<tenant>"
export ARM_SUBSCRIPTION_ID="<subscription-id>"

az login --service-principal \
  --username $ARM_CLIENT_ID \
  --password $ARM_CLIENT_SECRET \
  --tenant $ARM_TENANT_ID

az account show

If the login succeeds and az account show returns your subscription, the credentials are valid and Terraform will be able to authenticate using the same environment variables from the pipeline.

# Clean up the local test session
az logout

You are now ready to wire these credentials into a Terraform pipeline.

Step 7.2.4: Write a basic Terraform pipeline

Copy your existing Azure Terraform code into the pipeline repository:

cp -r $LAB_ROOT/azure/. $LAB_ROOT/pipeline/

Create .gitlab-ci.yml at the root of your repository. We use the official HashiCorp image for now — in Lab 7.4 you will replace it with a custom builder image that also contains tflint and additional tooling.

---
image: hashicorp/terraform:1.12.2

stages:
  - validate
  - plan

variables:
  TF_VAR_FILE: "config/dev.tfvars"
  TF_BACKEND_CONFIG: "config/dev_backend.tfvars"
  TF_PLUGIN_CACHE_DIR: "/cache/plugin-cache"
  TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE: "1"

before_script:
  - mkdir -p $TF_PLUGIN_CACHE_DIR
  - terraform init -backend-config=$TF_BACKEND_CONFIG

validate:
  stage: validate
  script:
    - terraform validate

plan:
  stage: plan
  script:
    - terraform plan -var-file=$TF_VAR_FILE -out=tfplan
  artifacts:
    paths:
      - tfplan
    expire_in: 1 day

Push to GitLab and watch the pipeline run:

git add .gitlab-ci.yml
git commit -m "ci: add terraform validate and plan pipeline"
git push

Navigate to CI/CD → Pipelines in your GitLab project to see the result.

Explanation

The image: key sets the Docker image used for all jobs. Using the official HashiCorp image pins the Terraform version to match versions.tf.

The before_script: block runs before every job script, making terraform init a single place to maintain.

Saving the plan as an artifact lets the apply job (added next) use the exact same plan that was reviewed — preventing drift between plan and apply.

Step 7.2.5: Add a manual apply job

Extend .gitlab-ci.yml by appending the following:

apply:
  stage: apply
  script:
    - terraform apply -auto-approve tfplan
  dependencies:
    - plan
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
      when: manual
  environment:
    name: production

Also extend the stages: list:

stages:
  - validate
  - plan
  - apply

Push the change, open a merge request, and observe that the apply job appears but requires a manual click to execute.

Explanation

rules: when: manual means the job is created but waits for a human to click the play button. Restricting it to the main branch ensures feature branches only run validate and plan — apply only happens after a merge.

The dependencies: key tells GitLab to download the tfplan artifact from the plan job so the apply step uses the reviewed plan exactly.