DevOps & Performance Engineering · Aug 2026 · 14 min read

Automate Azure Load Testing Using GitHub Actions

Wiring Azure Load Testing into a GitHub Actions pipeline so every pull request gets an automated, pass/fail-gated performance test — based on the pattern in Microsoft's Automate Azure Load Testing Applied Skills path.

Why Performance Testing Belongs in the Pipeline, Not Before Release

Most teams that have load testing at all run it as a pre-release ritual: someone spins up a JMeter run a day or two before a big deployment, eyeballs the dashboard, ships. The problem isn't that the test happens — it's when. A regression introduced three sprints ago doesn't surface until that pre-release run, by which point tracing it back to the specific commit means bisecting through dozens of merges.

This guide follows the same shape as Microsoft's Automate Azure Load Testing by using GitHub Actions Applied Skills module: wire Azure Load Testing into GitHub Actions so every pull request carries its own load test result, with pass/fail criteria that gate the merge exactly like a failing unit test would.


1. Azure Load Testing, Briefly

Azure Load Testing is a fully managed load-testing service built on Apache JMeter — you either author a JMeter test plan (.jmx) for complex multi-step scenarios, or use a URL-based test for the common case of hammering a single endpoint with configurable virtual users, ramp-up time, and duration. Both approaches accept the same pass/fail criteria and both integrate identically into a pipeline.

For a CI/CD gate, the URL-based test is usually the right starting point — it's a YAML config, not a JMeter plan, so it lives comfortably next to application code and is trivial for any engineer to read and modify in a pull request.


2. Authentication: Federated Credentials, Not Secrets

Before the how, the what — three terms this section leans on:

Put together: the pipeline needs to authenticate to Azure to provision and run the load test. The wrong way is a client secret stored in GitHub Secrets — a long-lived credential that has to be rotated, and is a real liability if a workflow file is ever misconfigured to leak it in logs. The right way is Microsoft Entra Workload ID federation: GitHub's OIDC token issuer is registered as a federated credential on an Entra app registration scoped to just the Load Testing resource. At runtime, the pipeline presents its short-lived OIDC token, Azure verifies it against that trust rule, and hands back an access token — the whole exchange happens without a secret existing anywhere to begin with.

permissions:
  id-token: write   # required for OIDC — the workflow can't request a token without this
  contents: read

steps:
  - name: Azure login (OIDC, no secrets)
    uses: azure/login@v2
    with:
      client-id: ${{ vars.AZURE_CLIENT_ID }}
      tenant-id: ${{ vars.AZURE_TENANT_ID }}
      subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

Note these are vars, not secrets — a client ID, tenant ID, and subscription ID aren't secret values; only the trust relationship (configured once, in Azure, between the Entra app and this specific GitHub repo/branch) makes the authentication work. There's no credential in this file to leak.

Setting it up, end to end, with az and gh:

# 0. Confirm both CLIs are installed and you're logged in
gh --version || brew install gh
az --version || brew install azure-cli
gh auth login
az login

# 1. Variables for this setup
export APP_NAME="payments-api-loadtest-gha"
export RG_NAME="rg-payments-perf"
export REPO="raphgm/YOUR_REPO_NAME"
export SUBSCRIPTION_ID=$(az account show --query id -o tsv)
export TENANT_ID=$(az account show --query tenantId -o tsv)

# 2. Create the app registration + service principal
az ad app create --display-name "$APP_NAME"
export APP_ID=$(az ad app list --display-name "$APP_NAME" --query "[0].appId" -o tsv)
az ad sp create --id "$APP_ID"

# 3. Scope it to only the resource group the load test runs in — never subscription-wide
az role assignment create \
  --assignee "$APP_ID" \
  --role "Load Test Contributor" \
  --scope "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RG_NAME"

# 4. The federated credential is what makes OIDC work — this is the actual trust relationship,
#    not a secret. Match the subject to how the workflow triggers.
az ad app federated-credential create \
  --id "$APP_ID" \
  --parameters '{
    "name": "github-actions-main",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:'"$REPO"':ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'

# add a second one if pull_request-triggered runs also need it
az ad app federated-credential create \
  --id "$APP_ID" \
  --parameters '{
    "name": "github-actions-pr",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:'"$REPO"':pull_request",
    "audiences": ["api://AzureADTokenExchange"]
  }'

# 5. Store the three values as repo VARIABLES (not secrets — none of these three are sensitive)
gh variable set AZURE_CLIENT_ID --repo "$REPO" --body "$APP_ID"
gh variable set AZURE_TENANT_ID --repo "$REPO" --body "$TENANT_ID"
gh variable set AZURE_SUBSCRIPTION_ID --repo "$REPO" --body "$SUBSCRIPTION_ID"

2b. The Alternative: Client-Secret Authentication

Not every setup can use OIDC on day one — some teams already have an existing service principal, or a compliance process built around a stored credential. azure/login@v1 (the predecessor to the v2 OIDC-first version) authenticates with a single JSON blob instead:

{
  "clientId": "...",
  "clientSecret": "...",
  "subscriptionId": "...",
  "tenantId": "..."
}

Generating it, in one command:

az ad sp create-for-rbac \
  --name "payments-api-loadtest-gha" \
  --role "Load Test Contributor" \
  --scopes "/subscriptions/$(az account show --query id -o tsv)/resourceGroups/rg-payments-perf" \
  --sdk-auth

--sdk-auth is what produces exactly that JSON shape. The clientSecret in the output is shown once — Azure doesn't store it in retrievable form, so copy it immediately or you'll have to reset it.

If the app registration already exists (say, from the OIDC setup above) and you just need to add a secret credential to it without disturbing the federated credential:

export APP_ID="<existing clientId>"
NEW_SECRET=$(az ad app credential reset --id "$APP_ID" --append --query password -o tsv)

--append adds a new secret alongside any existing federated credential rather than replacing it — useful if you're running both auth methods side by side during a migration.

Storing it as a GitHub secret (unlike the OIDC values, this one is genuinely sensitive):

AZURE_CREDENTIALS=$(az ad sp create-for-rbac \
  --name "payments-api-loadtest-gha" \
  --role "Load Test Contributor" \
  --scopes "/subscriptions/$(az account show --query id -o tsv)/resourceGroups/rg-payments-perf" \
  --sdk-auth)

echo "$AZURE_CREDENTIALS" | gh secret set AZURE_CREDENTIALS --repo raphgm/YOUR_REPO_NAME
gh secret list --repo raphgm/YOUR_REPO_NAME   # confirms it exists; value itself is never shown back
- name: Azure login (client secret)
  uses: azure/login@v1
  with:
    creds: ${{ secrets.AZURE_CREDENTIALS }}

The tradeoff, plainly: this works, and it's still common in the wild — but it's exactly the long-lived, rotatable, leak-if-a-log-is-misconfigured credential the OIDC section above was built to eliminate. Reach for it when a specific tool or compliance requirement demands the JSON-blob format; default to OIDC otherwise.


3. The Test Configuration

# loadtest-config.yaml
version: v0.1
testId: payments-api-pr-check
testPlan: null              # omit for a URL-based test
engineInstances: 1
failureCriteria:
  - avg(response_time_ms) > 800
  - percentage(error) > 1
  - avg(latency) > 500
requestUrls:
  - url: https://payments-api-staging.example.com/health
    method: GET
    headers:
      Content-Type: application/json

failureCriteria is the entire point — these thresholds are what turn a load test from "a graph someone looks at" into "an automated gate." Set them from actual SLOs, not guesses: if the payment API's real production SLO is p95 < 800ms, that's the number that belongs here, not an arbitrary round figure.


4. The GitHub Actions Workflow

name: PR Load Test

on:
  pull_request:
    branches: [ "main" ]

permissions:
  id-token: write
  contents: read

jobs:
  load-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Azure login (OIDC)
        uses: azure/login@v2
        with:
          client-id: ${{ vars.AZURE_CLIENT_ID }}
          tenant-id: ${{ vars.AZURE_TENANT_ID }}
          subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}

      - name: Run Azure Load Test
        uses: azure/load-testing@v1
        with:
          loadTestConfigFile: 'loadtest-config.yaml'
          loadTestResource: 'payments-api-loadtest'
          resourceGroup: 'rg-payments-perf'

      - name: Upload test results
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: load-test-results
          path: ${{ github.workspace }}/loadTest

The official azure/load-testing Action handles provisioning the test run, polling for completion, and — critically — failing the job if the configured criteria aren't met. No custom polling script, no manual "did it pass" judgment call.


5. Wiring In Server-Side Diagnostics

A failed load test that just says "error rate exceeded 1%" isn't enough to act on. Connect the target environment's Application Insights so a failure comes with the actual server-side trace attached — slow downstream dependency calls, database connection pool exhaustion, GC pauses — in the same pull request, not a follow-up investigation days later.

az monitor app-insights component connect-webapp \
  --app payments-api-insights \
  --web-app payments-api-staging \
  --resource-group rg-payments-perf

With this wired in, a failed avg(response_time_ms) > 800 criterion in the load test result links directly to the Application Insights trace for the slowest requests in that same time window — the engineer who introduced the regression gets the root cause, not just the symptom.


6. What This Actually Buys You


Closing Thoughts

The pattern here isn't specific to Azure Load Testing — it's the same "make the expensive-to-remember manual check into a cheap, automatic pipeline gate" idea that CI/CD applies to tests, linting, and security scanning. Performance regressions are just another category of bug; the fix is treating them like one, with a pipeline that catches them before they ship rather than after.

GitHub Repository: azure-load-testing-github-actions-lab — the full working lab: OIDC-authenticated workflow, Bicep IaC for the Load Testing resource, and a Python verification script.

Azure Load Testing · GitHub Actions · CI/CD · Performance Engineering · OIDC · Application Insights