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'
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
- Not setting
permissions: id-token: writein the workflow. OIDC requires the workflow to have permission to request an ID token. Without this permission in the workflow YAML, theazure/loginaction will fail with a cryptic error about the token not being available. This permission must be set at the workflow or job level. - Using a single federated credential for all workflow triggers. A federated credential’s
subjectclaim must match the exact trigger context —refs/heads/mainfor push to main,pull_requestfor PR runs,environment:productionfor environment-gated jobs. Create separate federated credentials for each distinct subject pattern your workflows use. - 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.
Summary
- GitHub Actions deploys to Azure using OIDC — configure a federated credential on an App Registration that trusts your repo and workflow.
- The
azure/loginaction authenticates the workflow to Azure. All subsequent Azure CLI or AzureRM API calls run under the authenticated identity. - Use GitHub Environments to enforce protection rules (required reviewers, branch restrictions) on production deployments.
- For Terraform, set
ARM_USE_OIDC=trueand the azurerm provider uses the GitHub OIDC token automatically — noARM_CLIENT_SECRETneeded. - Post Terraform plan output as PR comments using the
actions/github-scriptaction to give reviewers visibility before approving infrastructure changes.
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).