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:
- Build the static sites from the frontend code in GitHub Actions
- Publish the static sites to Azure Blob Storage.
- 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!
terraform {
required_providers {
azuread = {
source = "hashicorp/azuread"
version = "< 4"
}
}
required_version = "< 2"
}
variable "github_org" {
type = string
nullable = false
}
variable "github_repo" {
type = string
nullable = false
}
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.
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
}
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.
- Service Principal.
- User-Assigned Managed Identity.
- 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.
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "< 5"
}
random = {
source = "hashicorp/random"
version = "< 4"
}
}
required_version = "< 2"
}
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.
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)
}
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 dependency
12 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¶
terraform {
required_providers {
github = {
source = "integrations/github"
version = "< 7"
}
}
required_version = "< 2"
}
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.
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.
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,
]
}
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:




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:
- 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
-
https://www.microsoft.com/en-us/security/business/security-101/what-is-openid-connect-oidc ↩
-
https://github.com/gruntwork-io/terragrunt/releases/tag/v0.77.1 ↩
-
https://learn.microsoft.com/en-us/cli/azure/install-azure-cli ↩
-
https://learn.microsoft.com/en-us/graph/api/resources/federatedidentitycredentials-overview?view=graph-rest-1.0 ↩
-
https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect ↩
-
https://learn.microsoft.com/en-us/entra/identity/managed-identities-azure-resources/how-manage-user-assigned-managed-identities#create-a-user-assigned-managed-identity ↩
-
https://registry.terraform.io/providers/hashicorp/azuread/latest/docs/guides/azure_cli ↩
-
https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure ↩
-
https://learn.microsoft.com/en-us/azure/cdn/create-profile-endpoint-terraform?tabs=azure-cli ↩
-
https://registry.terraform.io/providers/hashicorp/azurerm/4.24.0/docs/resources/cdn_endpoint_custom_domain ↩
-
https://terragrunt.gruntwork.io/docs/features/stacks/#passing-outputs-between-units ↩
-
https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-azure ↩
-
https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blobs-static-site-github-actions?tabs=openid ↩
-
https://github.com/Azure/login?tab=readme-ov-file#login-with-openid-connect-oidc-recommended ↩
-
https://learn.microsoft.com/en-us/cli/azure/storage/blob?view=azure-cli-latest#az-storage-blob-sync ↩
-
https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/manage-resource-groups-portal ↩
-
https://github.com/gruntwork-io/cloud-nuke/releases/tag/v0.40.0 ↩
-
https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens ↩