GitHub Actions for Azure Deployments

GitHub Actions can deploy directly to Azure using the same OIDC authentication model as Azure Pipelines — no secrets stored, short-lived tokens, scoped permissions. This page walks through the OIDC setup, the core Azure actions, and complete workflow examples for App Service, Container Apps, and AKS.

Configuring OIDC authentication

OIDC removes the need to store a client secret in GitHub Secrets. Instead, GitHub issues a signed JWT per workflow run, and Azure trusts it because you configured a federated credential that accepts JWTs from your specific repository and workflow.

# Step 1: Create an App Registration (service principal)
APP_ID=$(az ad app create \
  --display-name "github-actions-myapp" \
  --query appId -o tsv)

SP_OBJECT_ID=$(az ad sp create --id $APP_ID --query id -o tsv)

# Step 2: Assign the service principal appropriate roles
# Scope to a specific resource group — not the whole subscription
az role assignment create \
  --assignee $SP_OBJECT_ID \
  --role Contributor \
  --scope /subscriptions/$(az account show --query id -o tsv)/resourceGroups/myapp-prod-rg

# Step 3: Create a federated credential for the main branch
az ad app federated-credential create \
  --id $APP_ID \
  --parameters '{
    "name": "github-main-branch",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:myorg/myrepo:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'

# Step 4: Create a federated credential for pull requests
az ad app federated-credential create \
  --id $APP_ID \
  --parameters '{
    "name": "github-pull-requests",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:myorg/myrepo:pull_request",
    "audiences": ["api://AzureADTokenExchange"]
  }'

# Step 5: Store these in GitHub repository secrets (NOT the client secret)
echo "Add these to GitHub Secrets:"
echo "AZURE_CLIENT_ID: $APP_ID"
echo "AZURE_TENANT_ID: $(az account show --query tenantId -o tsv)"
echo "AZURE_SUBSCRIPTION_ID: $(az account show --query id -o tsv)"

Deploying to Azure App Service

# .github/workflows/deploy-app-service.yml
name: Deploy to App Service

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  id-token: write   # required for OIDC
  contents: read

env:
  AZURE_WEBAPP_NAME: my-webapp
  AZURE_RESOURCE_GROUP: myapp-prod-rg
  DOTNET_VERSION: '8.0.x'

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    outputs:
      artifact-path: ${{ steps.publish.outputs.artifact-path }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup .NET
        uses: actions/setup-dotnet@v4
        with:
          dotnet-version: ${{ env.DOTNET_VERSION }}

      - name: Restore
        run: dotnet restore

      - name: Build
        run: dotnet build --no-restore --configuration Release

      - name: Test
        run: dotnet test --no-build --configuration Release --logger trx

      - name: Publish
        run: dotnet publish --no-build --configuration Release --output ./publish

      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: webapp-build
          path: ./publish
          retention-days: 7

  deploy-staging:
    name: Deploy to Staging Slot
    needs: build
    runs-on: ubuntu-latest
    environment: staging   # GitHub environment with protection rules
    if: github.ref == 'refs/heads/main'

    steps:
      - name: Download artifact
        uses: actions/download-artifact@v4
        with:
          name: webapp-build
          path: ./publish

      - name: Login to Azure with OIDC
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Deploy to staging slot
        uses: azure/webapps-deploy@v3
        with:
          app-name: ${{ env.AZURE_WEBAPP_NAME }}
          slot-name: staging
          package: ./publish

  deploy-production:
    name: Swap to Production
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production   # requires approval in GitHub environment settings

    steps:
      - name: Login to Azure
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Swap staging to production
        uses: azure/cli@v2
        with:
          inlineScript: |
            az webapp deployment slot swap \
              --name ${{ env.AZURE_WEBAPP_NAME }} \
              --resource-group ${{ env.AZURE_RESOURCE_GROUP }} \
              --slot staging \
              --target-slot production

Deploying to Azure Container Apps

# .github/workflows/deploy-container-apps.yml
name: Build and Deploy Container App

on:
  push:
    branches: [main]

permissions:
  id-token: write
  contents: read
  packages: write

env:
  REGISTRY: myappregistry.azurecr.io
  IMAGE_NAME: myapp/api
  CONTAINER_APP_NAME: myapp-api
  CONTAINER_APP_RG: myapp-prod-rg

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set image tag
        run: |
          SHORT_SHA=$(git rev-parse --short=8 HEAD)
          echo "IMAGE_TAG=sha-${SHORT_SHA}" >> $GITHUB_ENV

      - name: Login to Azure
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Login to ACR
        run: az acr login --name myappregistry

      - name: Build and push image
        run: |
          docker build \
            -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}" \
            -t "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" \
            .
          docker push "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}"
          docker push "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"

      - name: Deploy to Container Apps
        uses: azure/container-apps-deploy-action@v1
        with:
          registryUrl: ${{ env.REGISTRY }}
          containerAppName: ${{ env.CONTAINER_APP_NAME }}
          resourceGroup: ${{ env.CONTAINER_APP_RG }}
          imageToDeploy: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}

Terraform deployments with GitHub Actions

# .github/workflows/terraform.yml
name: Terraform

on:
  push:
    branches: [main]
    paths: ['infra/**']
  pull_request:
    paths: ['infra/**']

permissions:
  id-token: write
  contents: read
  pull-requests: write   # to post plan output as PR comment

jobs:
  terraform:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra/environments/prod

    steps:
      - uses: actions/checkout@v4

      - name: Login to Azure
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: '1.7.x'

      - name: Terraform Init
        run: terraform init
        env:
          ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
          ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          ARM_USE_OIDC: 'true'

      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan
        env:
          ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
          ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          ARM_USE_OIDC: 'true'

      # Post plan output as a PR comment
      - name: Post plan to PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const output = `#### Terraform Plan\n\`\`\`\n${{ steps.plan.outputs.stdout }}\n\`\`\``;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            })

      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply tfplan
        env:
          ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
          ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          ARM_USE_OIDC: 'true'
Tip

For Terraform with GitHub Actions OIDC, set ARM_USE_OIDC=true in the environment. The azurerm provider reads this flag and uses the OIDC token from the ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL environment variables that GitHub injects automatically. You do not need to set ARM_CLIENT_SECRET at all.

Common mistakes

  1. Not setting permissions: id-token: write in the workflow. OIDC requires the workflow to have permission to request an ID token. Without this permission in the workflow YAML, the azure/login action will fail with a cryptic error about the token not being available. This permission must be set at the workflow or job level.
  2. Using a single federated credential for all workflow triggers. A federated credential’s subject claim must match the exact trigger context — refs/heads/main for push to main, pull_request for PR runs, environment:production for environment-gated jobs. Create separate federated credentials for each distinct subject pattern your workflows use.
  3. Storing AZURE_CLIENT_SECRET in GitHub Secrets “just in case.” If you have set up OIDC, you do not need the client secret. Storing it anyway is unnecessary risk — it becomes a second credential that can expire, be leaked, or be used by someone who should not have it. Remove it from Secrets once OIDC is working.

Frequently asked questions

Should I use GitHub Actions or Azure Pipelines to deploy to Azure?

If your code is on GitHub, GitHub Actions is the natural choice — the integration is tighter, the setup is simpler, and the ecosystem of Azure actions is well-maintained by Microsoft. If your organization is invested in Azure DevOps for project management, work items, and gate approvals, Azure Pipelines may fit better. Many teams use both: GitHub for code and GitHub Actions for CI, Azure DevOps Boards for planning.

How do I authenticate GitHub Actions to Azure without a client secret?

Use OpenID Connect (OIDC). Configure a federated credential on an Azure AD App Registration that trusts your GitHub repository and workflow. In the workflow, the azure/login action exchanges a short-lived GitHub token for an Azure access token. No client secret is stored in GitHub Secrets.

Are GitHub-hosted runners inside my Azure VNet?

No. GitHub-hosted runners run on GitHub infrastructure outside your VNet. They can reach public Azure endpoints. For private resources (App Service with private endpoints, private AKS clusters), you need self-hosted runners inside your VNet, or you can use the AzureRM runner group with VNet injection (currently in preview).

Last verified: 19 March 2026 Cloud services change frequently. Verify details against official documentation before making infrastructure decisions.