Skip to content

Deploy Static Sites to Azure CDN with GitHub Actions OIDC

In this blog post you will learn how to authenticate and deploy your frontend code to Azure CDN, backed by Azure Blob Storage to deliver low-latency static website to your users.

The objective is to avoid hard-coded credentials and only employ OpenID Connect to establish trust relationship between the Identity Provider (GitHub) and the Service Provider (Azure).

Introduction

Among many different technologies and tools in 2025, one of the most interesting innovation of our time is the OpenID Connect1.

It opens a lot of doors to the administrator(s) of the system, enabling secret-less authentication between different providers, promoting the principle of least privilege, and removing the need for rotating hard-coded credentials.

While it does so, it makes sure that adversaries and untrusted parties will never gain access to our infrastructure, closing the doors that never ought to be!

If you've already read the article from last week, you have a good idea of what I'm talking about.

I will spare you the details and give you the gist.

The idea is pretty simple actually.

You establish a trust relationship between two providers, and then grant the required permission & RBAC to let the identities of one system to perform API calls to the other.

This is the diagram we'll cover this week. 👇

sequenceDiagram
    actor Identity as GitHub runner job
    participant IDP as GitHub Actions
    participant SP as Azure

    IDP->>SP: Establish Trust Relationship
    Note right of SP: Trust relationship established

    Identity->>SP: Authenticate
    alt Trust Established
        SP->>Identity: Access Granted
    else No Trust Established
        SP->>Identity: 401 Unauthorized
    end

That sums up the whole objective of this blog post. But, first, let's nail down exactly what we aim to achive.

The Big Picture

This is the highlight of our mission here:

  1. Build the static sites from the frontend code in GitHub Actions
  2. Publish the static sites to Azure Blob Storage.
  3. Configure Azure CDN to deliver those assets over low-latency global network

The following is the directory structure of our Infrastructure as Code:

.
├── 10-azure-github-trust
│   ├── main.tf
│   └── terragrunt.hcl
├── 20-azure-cdn-blob
│   ├── main.tf
│   └── terragrunt.hcl
└── 30-github-repository
    ├── files
    │   └── ci.yml
    ├── main.tf
    └── terragrunt.hcl

And these are the prerequisites:

OIDC Trust Relationship Between GitHub and Azure

Let's jump right in.

We want to let our Azure account know that the CI/CD runners of a specific repository are to be trusted.

This trust will let the two services talk to one another without the need for us to provide client-id and client-secret, or any other type of hard-coded credentials, all thanks to the power of OpenID Connect.

OpenID Connect in Azure

Beware that Azure, just like any other cloud provider, like playing names.

They call OpenID Connect differently; Federated Identity Credentials5.

Quite a mouthful and it is just marketing.

The ideas are solid and firm based on OpenID Connect though! 🙄

10-azure-github-trust/versions.tf
terraform {
  required_providers {
    azuread = {
      source  = "hashicorp/azuread"
      version = "< 4"
    }
  }

  required_version = "< 2"
}
10-azure-github-trust/variables.tf
variable "github_org" {
  type     = string
  nullable = false
}

variable "github_repo" {
  type     = string
  nullable = false
}
10-azure-github-trust/main.tf
data "azuread_client_config" "current" {}

resource "azuread_application" "this" {
  display_name     = "github-oidc-${var.github_repo}"
  owners           = [data.azuread_client_config.current.object_id]
  sign_in_audience = "AzureADMyOrg"
}

resource "azuread_service_principal" "this" {
  client_id                    = azuread_application.this.client_id
  app_role_assignment_required = false
  owners                       = [data.azuread_client_config.current.object_id]
}

resource "azuread_application_federated_identity_credential" "this" {
  application_id = azuread_application.this.id
  display_name   = "github-actions-oidc"
  description    = "GitHub Actions OIDC for ${var.github_org}/${var.github_repo}"
  audiences      = ["api://AzureADTokenExchange"]
  issuer         = "https://token.actions.githubusercontent.com"
  subject        = "repo:${var.github_org}/${var.github_repo}:ref:refs/heads/main"
}

You will realize that we're using Azure Service Principal to authenticate GitHub runner jobs6.

If you don't have enough permission to configure Azure Entra ID, then your second best bet will be to use Azure User-Assigned Managed Identity7. We have a concrete example in the blog post from last week.

10-azure-github-trust/outputs.tf
output "client_id" {
  value = azuread_application.this.client_id
}

output "service_principal_guid" {
  value = azuread_service_principal.this.object_id
}

output "tenant_id" {
  value = data.azuread_client_config.current.tenant_id
}
10-azure-github-trust/terragrunt.hcl
inputs = {
  github_org  = "developer-friendly"
  github_repo = "deploy-frontend-to-azure-cdn"
}

Have your Azure CLI authenticated8 and run the following:

# These need to be present in your CLI
export ARM_SUBSCRIPTION_ID="00000000-0000-0000-0000-000000000000"
export ARM_TENANT_ID="00000000-0000-0000-0000-000000000000"

terragrunt init -upgrade
terragrunt plan -out tfplan
terragrunt apply tfplan

And the output will be similar to the following:

client_id = "00000000-0000-0000-0000-000000000000"
service_principal_guid = "00000000-0000-0000-0000-000000000000"
tenant_id = "00000000-0000-0000-0000-000000000000"

Authenticate GitHub Runners to Azure

There are generally three types of available from GitHub Actions to Azure cloud9.

  1. Service Principal. ✅
  2. User-Assigned Managed Identity. ✅
  3. Service Principal and Secret. ❌

The last one is not recommended because it requires passing hard-coded credentials, the very first thing this blog post is aiming to avoid! 🙇‍♂️

Provisioning Azure Blob Storage and CDN

We will heavily rely on the official documentation for creating Azure CDN using OpenTofu code10.

20-azure-cdn-blob/versions.tf
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "< 5"
    }
    random = {
      source  = "hashicorp/random"
      version = "< 4"
    }
  }

  required_version = "< 2"
}
20-azure-cdn-blob/variables.tf
variable "service_principal_guid" {
  type     = string
  nullable = false
}
20-azure-cdn-blob/main.tf
data "azurerm_client_config" "current" {}

resource "random_pet" "this" {
  for_each = toset([
    "storage_account",
    "cdn_profile",
    "cdn_endpoint",
  ])

  length = 3
}

resource "azurerm_resource_group" "this" {
  name     = "deploy-static-site-to-az"
  location = "Germany West Central"
}

resource "azurerm_storage_account" "this" {
  name                     = replace(random_pet.this["storage_account"].id, "-", "")
  resource_group_name      = azurerm_resource_group.this.name
  location                 = azurerm_resource_group.this.location
  account_tier             = "Standard"
  account_replication_type = "LRS"
  account_kind             = "StorageV2"
}

resource "azurerm_storage_account_static_website" "this" {
  storage_account_id = azurerm_storage_account.this.id
  error_404_document = "index.html"
  index_document     = "index.html"
}

resource "azurerm_cdn_profile" "this" {
  name                = random_pet.this["cdn_profile"].id
  location            = "global"
  resource_group_name = azurerm_resource_group.this.name
  sku                 = "Standard_Microsoft"
}

resource "azurerm_cdn_endpoint" "this" {
  name                = random_pet.this["cdn_endpoint"].id
  profile_name        = azurerm_cdn_profile.this.name
  location            = "global"
  resource_group_name = azurerm_resource_group.this.name
  origin_host_header  = azurerm_storage_account.this.primary_web_host

  origin {
    name      = "azblob"
    host_name = azurerm_storage_account.this.primary_web_host
  }

  delivery_rule {
    name  = "EnforceHTTPS"
    order = 1

    request_scheme_condition {
      operator     = "Equal"
      match_values = ["HTTP"]
    }

    url_redirect_action {
      redirect_type = "PermanentRedirect"
      protocol      = "Https"
    }
  }
}

resource "azurerm_role_assignment" "blob_contributor" {
  scope                = azurerm_storage_account.this.id
  role_definition_name = "Storage Blob Data Contributor"
  principal_id         = var.service_principal_guid
}

resource "azurerm_role_assignment" "endpoint_contributor" {
  scope                = azurerm_cdn_endpoint.this.id
  role_definition_name = "CDN Endpoint Contributor"
  principal_id         = var.service_principal_guid
}

We can add custom domain to the Azure CDN11. But for the sake of simplicity and brevity, we'll skip that part.

20-azure-cdn-blob/outputs.tf
output "cdn_endpoint" {
  value = azurerm_cdn_endpoint.this.name
}

output "cdn_profile_name" {
  value = azurerm_cdn_profile.this.name
}

output "resource_group_name" {
  value = azurerm_resource_group.this.name
}

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

output "subscription_id" {
  value = data.azurerm_client_config.current.subscription_id
}

output "url" {
  value = format("https://%s", azurerm_cdn_endpoint.this.fqdn)
}
20-azure-cdn-blob/terragrunt.hcl
generate "azure" {
  path      = "provider_azurerm.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<-EOF
    provider "azurerm" {
      features {}
    }
  EOF
}

inputs = {
  service_principal_guid = dependency.oidc.outputs.service_principal_guid
}

dependency "oidc" {
  config_path = "../10-azure-github-trust"
}

Notice how we use the elegant dependency12 block of Terragrunt to pass the inputs from one stack to the other. It's beautiful. 😍

And this will be the output:

cdn_endpoint = "cleanly-quiet-doberman"
cdn_profile_name = "truly-dear-quagga"
resource_group_name = "deploy-static-site-to-az"
storage_account_name = "initiallydistincttarpon"
subscription_id = "00000000-0000-0000-0000-000000000000"
url = "https://cleanly-quiet-doberman.azureedge.net"

Publish Site to Azure Blob with GitHub Actions Workflow

30-github-repository/versions.tf
terraform {
  required_providers {
    github = {
      source  = "integrations/github"
      version = "< 7"
    }
  }

  required_version = "< 2"
}
30-github-repository/variables.tf
variable "azure_client_id" {
  type     = string
  nullable = false
}

variable "azure_subscription_id" {
  type     = string
  nullable = false
}

variable "azure_tenant_id" {
  type     = string
  nullable = false
}

variable "cdn_endpoint" {
  type     = string
  nullable = false
}

variable "cdn_profile_name" {
  type     = string
  nullable = false
}

variable "github_repo" {
  type     = string
  nullable = false
}

variable "resource_group" {
  type     = string
  nullable = false
}

variable "storage_account_name" {
  type     = string
  nullable = false
}

Again, we lean on the official docs for creating our CI/CD workflow13, with a few modifications and customizations. There are also official docs in Azure for the same requirement14.

30-github-repository/files/ci.yml
name: ci

on:
  push:
    branches:
      - main

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Setup bun
        uses: oven-sh/setup-bun@v2
        with:
          bun-version: latest
      - name: Install bun deps
        run: bun install
      - name: Cache node_modules
        uses: actions/cache@v4
        with:
          path: node_modules
          key: node-${{ runner.os }}-${{ hashFiles('**/bun.lock') }}
          restore-keys: |
            node-${{ runner.os }}-
            node-
      - name: Bun build
        run: bun run build
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: dist-${{ github.run_id }}-${{ github.run_attempt }}
          path: dist
      - name: Azure CLI Login
        uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      - name: Upload static files to Azure Storage
        uses: azure/cli@v2
        with:
          inlineScript: |
            az account show
            az storage blob upload-batch --account-name ${{ secrets.STORAGE_ACCOUNT_NAME }}  --auth-mode login -d '$web' -s dist --overwrite
            az cdn endpoint purge --content-paths  "/*" --profile-name ${{ secrets.CDN_PROFILE_NAME }} --name ${{ secrets.CDN_ENDPOINT }} --resource-group ${{ secrets.RESOURCE_GROUP }}

For a comprehensive list of all the availble inputs to Azure/login GitHub Action, refer to the upstream repository15.

30-github-repository/main.tf
data "github_repository" "this" {
  name = var.github_repo
}

resource "github_actions_secret" "this" {
  for_each = {
    AZURE_CLIENT_ID       = var.azure_client_id
    AZURE_SUBSCRIPTION_ID = var.azure_subscription_id
    AZURE_TENANT_ID       = var.azure_tenant_id
    CDN_ENDPOINT          = var.cdn_endpoint
    CDN_PROFILE_NAME      = var.cdn_profile_name
    RESOURCE_GROUP        = var.resource_group
    STORAGE_ACCOUNT_NAME  = var.storage_account_name
  }

  repository      = data.github_repository.this.name
  secret_name     = each.key
  plaintext_value = each.value
}

resource "github_repository_file" "ci" {
  repository          = data.github_repository.this.name
  branch              = "main"
  file                = ".github/workflows/ci.yml"
  content             = file("${path.module}/files/ci.yml")
  commit_message      = "chore(CI): add github workflow"
  commit_author       = "opentofu[bot]"
  commit_email        = "opentofu[bot]@users.noreply.github.com"
  overwrite_on_create = true

  depends_on = [
    github_actions_secret.this,
  ]
}
30-github-repository/terragrunt.hcl
inputs = {
  github_org  = "developer-friendly"
  github_repo = "deploy-frontend-to-azure-cdn"

  azure_client_id = dependency.oidc.outputs.client_id
  azure_tenant_id = dependency.oidc.outputs.tenant_id

  azure_subscription_id = dependency.cdn.outputs.subscription_id
  storage_account_name  = dependency.cdn.outputs.storage_account_name
  cdn_profile_name      = dependency.cdn.outputs.cdn_profile_name
  cdn_endpoint          = dependency.cdn.outputs.cdn_endpoint
  resource_group        = dependency.cdn.outputs.resource_group_name
}

dependency "oidc" {
  config_path = "../10-azure-github-trust"
}

dependency "cdn" {
  config_path = "../20-azure-cdn-blob"
}

Verify the Website

We have everything ready to test out if things are working correctly.

# The first run will result in TCP_MISS
$ curl -D - https://cleanly-quiet-doberman.azureedge.net
HTTP/2 200
date: Sun, 30 Mar 2025 11:05:05 GMT
content-type: text/html
content-length: 259
last-modified: Sun, 30 Mar 2025 10:16:43 GMT
etag: "0x8DD6F73F859103A"
x-ms-request-id: 650b28ca-201e-0003-2563-a1694c000000
x-ms-version: 2018-03-28
x-azure-ref: 20250330T110504Z-18477bc996c8lvv6hC1SG1zcrg000000030000000000cpey
x-fd-int-roxy-purgeid: 4
x-cache: TCP_MISS
accept-ranges: bytes

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Deploy Frontend to Azure CDN</title>
  </head>
  <body>
    <h1>Hello world!</h1>
  </body>
</html>

# But the second one will hit the cache
$ curl -D - https://cleanly-quiet-doberman.azureedge.net
HTTP/2 200
date: Sun, 30 Mar 2025 11:05:08 GMT
content-type: text/html
content-length: 259
last-modified: Sun, 30 Mar 2025 10:16:43 GMT
etag: "0x8DD6F73F859103A"
x-ms-request-id: 650b28ca-201e-0003-2563-a1694c000000
x-ms-version: 2018-03-28
x-azure-ref: 20250330T110508Z-18477bc996ckrzl5hC1SG1eyn0000000058000000000qbc8
x-fd-int-roxy-purgeid: 4
x-cache: TCP_HIT
x-cache-info: L1_T2
accept-ranges: bytes

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Deploy Frontend to Azure CDN</title>
  </head>
  <body>
    <h1>Hello world!</h1>
  </body>
</html>

Here are the screenshots for this experiment:

Azure Resource Group
Azure Resource Group
Azure Blob Storage IAM
Azure Blob Storage IAM
Login Action
Login Action
Upload Assets to Azure Blob
Upload Assets to Azure Blob

Future Works

You can improve this work further by pruning all the files in the destination that are not present in the source, for example because they have been removed.

This can be achieve with this command instead, which is using azcopy under the hood16:

30-github-repository/files/ci.yml
- name: Upload static files to Azure Storage
  uses: azure/cli@v2
  with:
    inlineScript: |
      az account show
      az storage blob sync --account-name ${{ secrets.STORAGE_ACCOUNT_NAME }} --auth-mode login -c '$web' -s dist --delete-destination true
      az cdn endpoint purge --content-paths  "/*" --profile-name ${{ secrets.CDN_PROFILE_NAME }} --name ${{ secrets.CDN_ENDPOINT }} --resource-group ${{ secrets.CDN_RESOURCE_GROUP }}

In addition, the current frontend code is pretty simplistic and just a hello-world application; when the code grows, you will likely require additional optimizations to improve the build time and reduce your CI billing.

That is obviously out of scope for this blog post. 🤓

What's Great About Azure?

As I close this blog post, I will go back to my Azure portal to remove the resources I've created.

The best part of working with Azure is that I will only have to remove one resource: the Resource Group17.

Unlike AWS and other cloud providers, I don't have to hunt down the resources I've provisioned in different tabs, services and regions.

Nor do I need to use external third-party tools such as cloudnuke18.

In Azure, only removing the Resource Group will remove all the child resources! 💪

Conclusion

With OpenID Connect, we can establish trust relationship between two service providers and let the identity(-ies) of one authenticate to the other without passing any hard-coded credentials.

These types of authentication are based on short-lived TTL credentials that are only authenticated for the brief moment that the actor needs to perform the API call.

With these short-lived credentials, you will never have to worry about secret rotation, or secret-leakage! It's a life-saver.

I will forever be grateful to all the wonderful genuises who delivered OpenID Connect to our world. It is one of the greatest inventions of our time.

It makes the lives of administratos a lot easier, while ensuring that our infrastructure is secure, compliant, and without hard-coded long-lived credentials.

I will continue to employ OpenID Connect now and forever, in any system that is OIDC-compliant.

I hope, one day, to get rid of GitHub PATs19 once the good folks there finally start supporting OIDC authentication!

Until next time 🫡, ciao 🤠 & happy coding! 🐧

Subscribe to Newsletter Subscribe to RSS Feed

Share on Share on Share on Share on


  1. https://www.microsoft.com/en-us/security/business/security-101/what-is-openid-connect-oidc 

  2. https://github.com/gruntwork-io/terragrunt/releases/tag/v0.77.1 

  3. https://github.com/opentofu/opentofu/releases/tag/v1.9.0 

  4. https://learn.microsoft.com/en-us/cli/azure/install-azure-cli 

  5. https://learn.microsoft.com/en-us/graph/api/resources/federatedidentitycredentials-overview?view=graph-rest-1.0 

  6. https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect 

  7. https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-manage-user-assigned-managed-identities#create-a-user-assigned-managed-identity 

  8. https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/guides/azure_cli 

  9. https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure 

  10. https://learn.microsoft.com/en-us/azure/cdn/create-profile-endpoint-terraform?tabs=azure-cli 

  11. https://registry.terraform.io/providers/hashicorp/azurerm/4.24.0/docs/resources/cdn_endpoint_custom_domain 

  12. https://terragrunt.gruntwork.io/docs/features/stacks/#passing-outputs-between-units 

  13. https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-azure 

  14. https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-static-site-github-actions?tabs=openid 

  15. https://github.com/Azure/login?tab=readme-ov-file#login-with-openid-connect-oidc-recommended 

  16. https://learn.microsoft.com/en-us/cli/azure/storage/blob?view=azure-cli-latest#az-storage-blob-sync 

  17. https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-portal 

  18. https://github.com/gruntwork-io/cloud-nuke/releases/tag/v0.40.0 

  19. https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens 

Comments