Table of Contents

  1. Introduction
  2. Prerequisites
  3. Creating the Storage Account and Enabling Static Website Hosting
  4. Custom Domains and HTTPS
  5. Creating the Identity GitHub Will Authenticate As
  6. The Federated Credential
  7. Granting the Role
  8. GitHub Repository Secrets
  9. The Workflow
  10. Two Upload Passes, Two Cache Policies
  11. Why the Deploy Never Deletes Anything
  12. CORS on the API
  13. Adding Front Door or CDN
  14. Verifying a Deployment
  15. Troubleshooting
  16. Conclusion

Introduction

A Preact application built with Vite compiles to a handful of static files — one index.html, a folder of fingerprinted JS and CSS, and whatever images you imported. There is no server-side rendering step and no Node process to keep alive, so paying for an App Service or a container to serve them is pure waste.

Azure Storage will serve those files directly. Every storage account can expose a special $web container over an anonymous HTTPS endpoint, complete with a configurable index document and a 404 fallback. It costs a few cents a month, scales without any configuration on your part, and needs no runtime to patch.

This guide wires that up end to end: the storage account, a GitHub Actions workflow that builds and uploads on every push to main, and — the part that most guides get wrong — authentication that stores no storage keys or connection strings in GitHub at all. Instead GitHub proves its identity to Microsoft Entra ID with a short-lived OIDC token, and Azure decides whether to trust it.

What You’ll Build

  • A StorageV2 account with static website hosting enabled
  • A user-assigned managed identity with a federated credential that trusts exactly one repository and one branch
  • A two-job GitHub Actions workflow: build once, upload the artifact, deploy with azure/login@v2
  • Cache headers that let hashed assets live forever while new deploys still appear instantly
  • Optional Azure Front Door in front of it all, with a cache purge step in the pipeline

Everything below uses the Azure CLI. The resource names are placeholders — substitute your own.

Prerequisites

Item Placeholder used in this guide
Resource group rg-preact-site
Storage account stpreactsite (3–24 chars, lowercase letters and digits, globally unique)
Region westus2
GitHub repository your-org/preact-site
Deploy branch main

You need the Azure CLI, logged in with az login, holding enough rights to create resources and assign roles on the subscription. On the application side this guide assumes a stock Vite + Preact

  • TypeScript project whose npm run build writes to dist/ and whose assets land in dist/assets/ with content hashes in their filenames — the Vite default.

Export the names once so the snippets that follow can be pasted as-is:

RG=rg-preact-site
ACCOUNT=stpreactsite
LOCATION=westus2
SUBSCRIPTION=$(az account show --query id -o tsv)

Creating the Storage Account and Enabling Static Website Hosting

Static website hosting requires the StorageV2 kind (or BlockBlobStorage). It is not available on the legacy Storage kind, and there is no way to upgrade your way out of that mistake other than creating a new account — so get the --kind right the first time.

az group create --name $RG --location $LOCATION

az storage account create \
  --name $ACCOUNT \
  --resource-group $RG \
  --location $LOCATION \
  --sku Standard_LRS \
  --kind StorageV2 \
  --min-tls-version TLS1_2 \
  --allow-blob-public-access true

Creating the account does not by itself create the $web container or turn on the website endpoint. That is a separate property on the blob service:

az storage account blob-service-properties update \
  --account-name $ACCOUNT \
  --resource-group $RG \
  --static-website true \
  --index-document index.html \
  --404-document index.html

Setting --404-document to index.html is what makes client-side routing work. A single-page app that grows past one route will eventually be linked to at /orders/42; the storage account has no blob at that path, so it serves the 404 document — your index.html — and the router takes over from there. Without it, deep links return a bare Azure 404 page.

Retrieve the public URL:

az storage account show --name $ACCOUNT --resource-group $RG \
  --query "primaryEndpoints.web" -o tsv
# -> https://stpreactsite.z5.web.core.windows.net/

--allow-blob-public-access true is not optional here. The static website endpoint serves $web anonymously; with public blob access disabled at the account level, the endpoint returns 404 for every request, including index.html. This only exposes $web — every other container keeps whatever access level you gave it, which is private by default.

Custom Domains and HTTPS

The *.web.core.windows.net endpoint comes with HTTPS out of the box. A custom domain does not.

Azure Storage can map a custom domain to the account, but it cannot terminate TLS for that domain — there is nowhere to install a certificate. Requests to https://www.example.com will fail the handshake. The fix is to put a service in front that owns the certificate:

  • Azure Front Door (recommended) — managed TLS certificates, global POPs, rules engine, and az afd endpoint purge for cache invalidation.
  • Azure CDN — the older classic profiles, same idea, purged with az cdn endpoint purge.

If you add either, the cache becomes a second place your files live, and the deploy has to invalidate it. That is covered in Adding Front Door or CDN below. Until then, deploy straight to the storage endpoint and keep the pipeline simple.

Creating the Identity GitHub Will Authenticate As

The old way to do this was to drop a storage account key into a GitHub secret. Keys are account-wide, never expire on their own, grant full data-plane access, and leak silently. Skip that.

Instead, create a user-assigned managed identity and attach a federated credential to it: a statement that says “a token issued by GitHub’s OIDC provider, for this exact repository and this exact branch, may act as this identity.” No secret is exchanged, nothing expires in a drawer somewhere, and revoking access is a single delete.

An app registration works identically, and the CLI command for the federated credential is shown below for that case too. A managed identity is simpler to audit because it cannot have a client secret at all.

IDENTITY=id-preact-site-deploy

az identity create --name $IDENTITY --resource-group $RG --location $LOCATION

CLIENT_ID=$(az identity show --name $IDENTITY --resource-group $RG --query clientId -o tsv)
TENANT_ID=$(az identity show --name $IDENTITY --resource-group $RG --query tenantId -o tsv)
PRINCIPAL_ID=$(az identity show --name $IDENTITY --resource-group $RG --query principalId -o tsv)

Keep those three values around — the first two become GitHub secrets, and PRINCIPAL_ID is what the role assignment targets.

The Federated Credential

This is the step that wastes the most time, so it is worth understanding before running anything.

Azure matches the subject claim as an exact string. There are no wildcards, no normalisation, no case folding. One character of difference and azure/login fails with AADSTS700213.

Do not hand-write the subject. Nearly every tutorial shows it as repo:owner/repository:ref:refs/heads/main, and GitHub has since started embedding immutable numeric owner and repository IDs into the claim. A real subject looks like this:

r e p o : y o o w u n r e - r o r g @ 1 o 2 w 3 n 4 e 5 r 6 I 7 D / p r r e e a p c o t - n s a i m t e e @ 9 8 r 7 e 6 p 5 o 4 I 3 D 2 1 : r e f r : e r f e f s / h e a d s / m a i n

Those @<id> suffixes are a security feature, not noise. They pin the credential to the account and repository as objects rather than as names, so your Azure trust survives a rename — and, more importantly, nobody who later registers a username or repo name you abandoned inherits your access.

Create it:

SUBJECT='repo:your-org@1234567/preact-site@987654321:ref:refs/heads/main'

az identity federated-credential create \
  --name github-main \
  --identity-name $IDENTITY \
  --resource-group $RG \
  --issuer https://token.actions.githubusercontent.com \
  --subject "$SUBJECT" \
  --audiences api://AzureADTokenExchange

Using an app registration instead of a managed identity:

az ad app federated-credential create --id <APP_ID> --parameters "{
  \"name\": \"github-main\",
  \"issuer\": \"https://token.actions.githubusercontent.com\",
  \"subject\": \"$SUBJECT\",
  \"audiences\": [\"api://AzureADTokenExchange\"]
}"

Reading the Subject Out of a Real Token

Since the numeric IDs are not guessable, get the subject from the token instead of composing it. Add this step temporarily to the deploy job, immediately before azure/login, and read the value from the run log:

      - name: Print OIDC subject
        run: |
          TOKEN=$(curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" | jq -r .value)
          # Decode the payload only. Never echo $TOKEN itself — it is a credential.
          echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | sed 's/$/===/' \
            | base64 -d 2>/dev/null | jq '{sub, aud, iss}'

Copy the printed sub verbatim into --subject, then delete the step. Note the comment: print the decoded payload, never the raw token. The token is a bearer credential and the run log is not the place for it.

If a run has already failed, you do not even need this step — the AADSTS700213 error message quotes the subject that was presented, and that string is equally authoritative and faster to get.

Subjects for Other Triggers

The ID-suffixed prefix stays the same for every trigger; only the tail changes:

Trigger Tail after repo:owner@id/repo@id:
Push to main ref:refs/heads/main
Any tag ref:refs/tags/*
Pull request pull_request
GitHub Environment production environment:production

Add one credential per trigger you need, up to 20 per identity.

A workflow_dispatch run on main presents the branch subject shown above. There is no separate subject for a manual run, so no extra credential is needed to allow the “Run workflow” button.

Confirm what is actually stored, rather than what you meant to store:

az identity federated-credential list \
  --identity-name $IDENTITY --resource-group $RG \
  --query "[].{name:name, subject:subject}" -o table

Granting the Role

Authentication is now solved; authorization is separate. The identity can log in but cannot write a byte until it holds a data-plane role.

Storage Blob Data Contributor is sufficient to upload blobs. Scope it to the storage account — never to the resource group or subscription, which would let a compromised workflow write anywhere.

az role assignment create \
  --assignee-object-id $PRINCIPAL_ID \
  --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Contributor" \
  --scope "/subscriptions/$SUBSCRIPTION/resourceGroups/$RG/providers/Microsoft.Storage/storageAccounts/$ACCOUNT"

Note that the control-plane roles — Owner, Contributor — do not grant data-plane access to blobs. Being the owner of the storage account does not let you write to $web under --auth-mode login. This surprises people regularly.

Role assignments take a minute or two to propagate. A first workflow run that fails with 403 AuthorizationPermissionMismatch immediately after you created the assignment usually just needs to be re-run.

GitHub Repository Secrets

In the repository: Settings → Secrets and variables → Actions → New repository secret.

Secret Value
AZURE_CLIENT_ID $CLIENT_ID from above
AZURE_TENANT_ID $TENANT_ID from above
AZURE_SUBSCRIPTION_ID $SUBSCRIPTION from above

None of these are secret in the way a key is — they are identifiers, and the federated credential is what actually gates access. Storing them as secrets is convention and keeps them out of logs, but leaking one does not compromise anything on its own.

Print them for copying:

echo "AZURE_CLIENT_ID       = $CLIENT_ID"
echo "AZURE_TENANT_ID       = $TENANT_ID"
echo "AZURE_SUBSCRIPTION_ID = $SUBSCRIPTION"

The Workflow

Committed at .github/workflows/deploy.yml:

name: Build and deploy frontend to Azure Storage

on:
  push:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: deploy-frontend
  cancel-in-progress: true

env:
  AZURE_STORAGE_ACCOUNT: stpreactsite

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read

    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: npm

      # `npm ci` installs exactly what package-lock.json pins, and fails if the
      # lockfile is out of sync with package.json.
      - run: npm ci

      # `npm run build` runs `tsc --noEmit && vite build`, so a type error fails the deploy.
      - run: npm run build

      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist
          if-no-files-found: error
          retention-days: 7

  deploy:
    runs-on: ubuntu-latest
    needs: build
    permissions:
      id-token: write   # required to request the OIDC token
      contents: read

    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist

      - name: Log in 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 }}

      # Pass 1 uploads the whole tree with the conservative header. Doing the
      # broad upload first means every hashed asset is already in place before
      # the new index.html that references it goes live.
      - name: Upload site (no-cache)
        uses: azure/cli@v2
        with:
          azcliversion: latest
          inlineScript: |
            az storage blob upload-batch \
              --account-name "${{ env.AZURE_STORAGE_ACCOUNT }}" \
              --auth-mode login \
              --destination '$web' \
              --source dist \
              --overwrite \
              --content-cache-control 'no-cache, must-revalidate'

      # Pass 2 re-uploads just assets/ to correct their header. Vite fingerprints
      # these filenames (index-C0tZ8Ixh.css), so a given URL's bytes never change
      # and it can be cached forever. index.html keeps the no-cache header from
      # pass 1, which is what makes a new deploy visible immediately.
      - name: Re-tag hashed assets as immutable
        uses: azure/cli@v2
        with:
          azcliversion: latest
          inlineScript: |
            az storage blob upload-batch \
              --account-name "${{ env.AZURE_STORAGE_ACCOUNT }}" \
              --auth-mode login \
              --destination '$web' \
              --destination-path assets \
              --source dist/assets \
              --overwrite \
              --content-cache-control 'public, max-age=31536000, immutable'

      - name: Report the site URL
        run: |
          echo "Deployed: $(az storage account show \
            --name '${{ env.AZURE_STORAGE_ACCOUNT }}' \
            --query 'primaryEndpoints.web' -o tsv)" >> $GITHUB_STEP_SUMMARY

A few decisions worth calling out:

Two jobs, not one. Only deploy gets id-token: write. The build job — the one that runs thousands of lines of third-party code from node_modules — never has the ability to request an Azure token. It hands over an artifact and nothing else.

concurrency with cancel-in-progress. Two deploys racing each other into the same container can interleave uploads and leave index.html pointing at assets from a different commit. Cancelling the older run removes that whole category of problem.

Type errors fail the deploy. Wiring tsc --noEmit into the build script rather than into a separate optional step means a type error stops the pipeline before anything reaches the storage account.

if-no-files-found: error. Without it, a build that silently produces nothing uploads an empty artifact and the deploy job cheerfully publishes a directory of zero files over your live site.

Two Upload Passes, Two Cache Policies

The two upload steps are the least obvious part of the workflow, and the reason for them is worth a moment.

Vite emits two categories of file with directly opposing caching needs:

  • index.html always lives at the same URL and its contents change on every deploy. It must never be cached, or visitors keep loading the old app.
  • assets/index-C0tZ8Ixh.js carries a content hash in its filename. If the contents change, the filename changes. The bytes behind any given URL are therefore immutable, and the file can be cached for a year.

Azure Storage sets Cache-Control per blob at upload time, and upload-batch applies a single --content-cache-control value to everything it uploads. Hence two passes: the first sweeps the whole tree with the safe, conservative header; the second re-uploads only assets/ to overwrite their header with the aggressive one.

Order matters. The broad upload runs first so that every new hashed asset is already sitting in $web before the index.html that references it becomes visible. Reverse the order and there is a window — small, but real — in which a visitor gets a fresh index.html pointing at assets that have not finished uploading.

The end result: a returning visitor re-downloads only index.html (a couple of KB), which then points at asset URLs their browser already has cached, and the new version appears on the next refresh with no hard reload and no cache-busting query strings.

Why the Deploy Never Deletes Anything

upload-batch adds and overwrites. It never removes blobs that are absent from the source. That is deliberate, not an oversight to patch.

Consider a visitor who loaded index.html ten seconds before a deploy. Their browser is still holding a reference to the previous build’s hashed assets, and will request them when they navigate or when a lazy chunk loads. If the deploy deleted those blobs, that visitor’s session breaks — a blank screen or a chunk-load error, for everyone who happened to be mid-visit.

Stale assets are a few KB apiece. Leaving them is cheap insurance. Prune on a schedule instead:

# Delete blobs under assets/ not touched by a deploy in the last 30 days.
az storage blob delete-batch \
  --account-name $ACCOUNT --auth-mode login \
  --source '$web' --pattern 'assets/*' \
  --if-unmodified-since $(date -u -v-30d '+%Y-%m-%dT%H:%MZ')

(The -v-30d syntax is BSD date, i.e. macOS. On GNU/Linux use date -u -d '30 days ago'.)

Or skip the manual step entirely and add a lifecycle management rule on the storage account that deletes blobs under assets/ after N days without modification.

CORS on the API

If your Preact app calls an API on a different origin — and it will, because the static site lives on *.web.core.windows.net while the API lives somewhere else — the API has to allow that origin explicitly. Nothing you configure on the storage side affects this.

In an ASP.NET Core API, define a named policy whose origins come from configuration:

builder.Services.AddCors(options =>
{
    options.AddPolicy("frontend", policy => policy
        .WithOrigins(builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? [])
        .WithMethods("GET", "POST", "DELETE")
        .AllowAnyHeader());
});

var app = builder.Build();

app.UseCors("frontend");
app.UseHttpsRedirection();

Register UseCors before UseHttpsRedirection. Otherwise a preflight OPTIONS request that arrives over HTTP is answered with a 307 redirect that carries no CORS headers, the browser refuses to follow it, and you get a CORS failure that looks nothing like a redirect problem.

appsettings.json:

"Cors": {
  "AllowedOrigins": [
    "https://stpreactsite.z5.web.core.windows.net"
  ]
}

Origins are scheme + host + port with no trailing slash. https://example.com/ does not match https://example.com. List every origin you actually serve from, including the Front Door domain if you add one. When overriding from App Service configuration, the array is addressed entry by entry — Cors__AllowedOrigins__0, Cors__AllowedOrigins__1, and so on — and the override replaces entries individually, so enumerate all of them rather than assuming the JSON defaults merge in.

Verify from the command line before blaming the browser:

curl -sI -H 'Origin: https://stpreactsite.z5.web.core.windows.net' \
  https://api.example.com/items/1 | grep -i access-control
#   Access-Control-Allow-Origin: https://stpreactsite.z5.web.core.windows.net

Local development typically calls the API same-origin through the Vite dev proxy configured in vite.config.ts, so localhost does not need to be whitelisted — which is exactly why CORS problems tend to show up for the first time in the deployed build.

Adding Front Door or CDN

You need this for a custom domain with HTTPS. Once an edge cache sits in front of the storage account, the deploy has a second copy of your site to worry about: index.html will keep serving from the POP until its TTL expires, no matter what you uploaded.

So the pipeline has to purge. Append this to the deploy job:

      - name: Purge Front Door cache
        uses: azure/cli@v2
        with:
          azcliversion: latest
          inlineScript: |
            az afd endpoint purge \
              --resource-group rg-preact-site \
              --profile-name afd-preact-site \
              --endpoint-name preact-site \
              --content-paths '/*' \
              --no-wait

For classic Azure CDN, az cdn endpoint purge takes the same --content-paths '/*'.

The identity also needs a role that permits purging — Front Door Domain Contributor for Front Door, CDN Endpoint Contributor for classic CDN. Storage Blob Data Contributor does not cover it, and the failure appears only at this final step, after a successful upload.

Strictly speaking only /index.html needs purging, since the hashed assets are marked immutable and their URLs never get reused. /* is simply the safe default, and --no-wait means the workflow does not sit idle while the purge propagates across POPs.

Verifying a Deployment

Check the headers rather than trusting a browser that may be serving you from its own cache:

URL=$(az storage account show --name $ACCOUNT --resource-group $RG --query "primaryEndpoints.web" -o tsv)

curl -sI "$URL" | grep -i 'http/\|content-type\|cache-control'
#   HTTP/1.1 200 OK
#   Content-Type: text/html
#   Cache-Control: no-cache, must-revalidate

# A hashed asset should come back immutable:
ASSET=$(curl -s "$URL" | grep -o '/assets/[^"]*\.js' | head -1)
curl -sI "$URL$ASSET" | grep -i 'cache-control'
#   Cache-Control: public, max-age=31536000, immutable

Two headers, two different values — that is the whole caching strategy confirmed in four lines.

Then open the site and exercise a route that calls the API. If the page renders but data never arrives, open the browser console: a CORS error there means the deployment is fine and the API’s allowed-origins list is not.

Troubleshooting

Symptom Cause
AADSTS700213: No matching federated identity record The --subject does not match the token byte for byte. The error message quotes the subject that was presented — copy it verbatim. Remember it carries @<ownerID> and @<repoID> suffixes and is not plain repo:owner/repo:....
Error: Login failed with Error: Unable to get ACTIONS_ID_TOKEN_REQUEST_URL The job is missing permissions: id-token: write.
403 AuthorizationPermissionMismatch on upload The role is not assigned, is still propagating, or --auth-mode login was omitted — without it the CLI falls back to looking for an account key it does not have.
Site returns 404 for everything Static website hosting is not enabled, --index-document is unset, or --allow-blob-public-access is false.
Deep links 404 but the home page works --404-document is not set to index.html.
Old version keeps loading after a deploy index.html was uploaded with a caching header, or a CDN in front was not purged.
API calls fail in the browser but work in curl CORS — the site’s origin is not in the API’s allowed origins.
npm ci fails with EUSAGE package-lock.json is out of sync with package.json. Run npm install locally and commit the lockfile.
Deploy succeeds but the site is empty The build produced no output and the artifact was empty. if-no-files-found: error on the upload step turns this into a build failure instead.

Conclusion

The finished setup has a short list of moving parts: a StorageV2 account with $web enabled, one managed identity carrying one federated credential, one role assignment scoped to that account, and a workflow whose deploy job is the only thing in the repository allowed to ask for an Azure token.

Three properties are worth keeping as you extend it. No long-lived credentials exist — nothing in GitHub can be stolen and replayed, and revoking access means deleting one federated credential. Trust is pinned to IDs, not names, so a repository rename does not break the pipeline and an abandoned name cannot be claimed into your subscription. And the caching split is structural: hashed assets immutable, index.html never cached, which delivers instant deploys and near-zero repeat bandwidth without any cache-busting tricks in the app.

From here the natural extensions are a pull_request credential and a preview environment in a second storage account, or a ref:refs/tags/* credential if you would rather deploy from tags than from main. Both are one more federated credential and a few lines of YAML — the hard part is already done.