Azure Apps Autopilot

A colleague once asked me a question that sounds simple but is genuinely annoying to solve well: can we hand someone a repository and let them provision the Azure resources and deploy the app in one action, without teaching them anything about the underlying infrastructure? For a proof of concept you want to share with a client or a new team member, that one-click experience matters more than it sounds like it should.

This walkthrough builds exactly that, an autopilot flow that provisions Azure Static Web Apps and Cosmos DB using Bicep, then deploys the app into that freshly created environment automatically, using GitHub Actions to tie the two steps together.

The Architecture

The sample stack is a Blazor WebAssembly front end, an Azure Functions backed API, and Cosmos DB for storage, all fitting neatly into Azure Static Web Apps, which bundles the static front end and its API together as one resource.

Blazor WASM front end and Azure Functions API served through Azure Static Web Apps, backed by Cosmos DB.
Blazor WASM front end and Azure Functions API served through Azure Static Web Apps, backed by Cosmos DB.

Provisioning Cosmos DB and Static Web Apps with Bicep

The Cosmos DB definition uses the serverless tier, which is the right call for a proof of concept, you pay per request rather than for a provisioned throughput tier sitting mostly idle between demos.

// cosmosDb.bicep
param resourceName string
param resourceLocation string
param databaseName string
param containerName string
 
resource cosdba 'Microsoft.DocumentDB/databaseAccounts@2021-10-15' = {
  name: 'cosdba-${resourceName}'
  location: resourceLocation
  kind: 'GlobalDocumentDB'
  properties: {
    capabilities: [ { name: 'EnableServerless' } ]
  }
}
 
resource cosdbasql 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2021-10-15' = {
  name: '${cosdba.name}/${databaseName}'
  properties: { resource: { id: databaseName } }
}
 
resource cosdbasqlcontainer 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2021-10-15' = {
  name: '${cosdbasql.name}/${containerName}'
  properties: {
    resource: {
      id: containerName
      partitionKey: { paths: [ '/category' ] }
    }
  }
}
 
output connectionString string = 'AccountEndpoint=https://${cosdba.name}.documents.azure.com:443/;AccountKey=${cosdba.listKeys().primaryMasterKey};'

The listKeys() call in the output is doing the heavy lifting here, it pulls the actual connection string out at deployment time so it can be passed straight into the Static Web App’s configuration without anyone copying a key by hand. Static Web Apps then gets its own Bicep definition on the free tier, wired up with that connection string.

// staticWebApp.bicep
param resourceName string
param resourceLocation string
@secure()
param cosmosDbConnectionString string
 
resource sttapp 'Microsoft.Web/staticSites@2021-03-01' = {
  name: 'sttapp-${resourceName}'
  location: resourceLocation
  sku: { name: 'Free' }
}
 
resource sttappconfig 'Microsoft.Web/staticSites/config@2021-03-01' = {
  name: '${sttapp.name}/appsettings'
  properties: {
    ConnectionStrings_CosmosDB: cosmosDbConnectionString
  }
}
 
output deploymentKey string = sttapp.listSecrets().properties.apiKey

Marking cosmosDbConnectionString with @secure() stops Bicep from writing it into deployment history logs in plain text, which matters even for a demo since deployment logs tend to outlive the demo itself. The deploymentKey output is the other credential this whole flow depends on later, it is what lets a separate CI/CD pipeline push app code into this Static Web App without needing full Azure login.

Tying It Together with a Subscription-Level Deployment

A main.bicep file orchestrates both modules, then a subscription-scoped azuredeploy.bicep creates the resource group itself and calls into main.bicep, using allowed value lists so a non-expert user gets a dropdown of sane regions instead of a free-text field they could get wrong.

// azuredeploy.bicep
targetScope = 'subscription'
 
param name string
@allowed([ 'Central US', 'East Asia', 'East US 2', 'Korea Central', 'West Europe', 'West US 2' ])
param cosmosDbLocation string = 'Korea Central'
param cosmosDbDatabaseName string = 'AdventureWorks'
param cosmosDbContainerName string = 'products'
@allowed([ 'Central US', 'East Asia', 'East US 2', 'West Europe', 'West US 2' ])
param staticAppLocation string = 'East Asia'
 
resource rg 'Microsoft.Resources/resourceGroups@2021-04-01' = {
  name: 'rg-${name}'
  location: cosmosDbLocation
}
 
module resources './main.bicep' = {
  name: 'Resources'
  scope: rg
  params: {
    resourceName: name
    cosmosDbLocation: rg.location
    cosmosDbDatabaseName: cosmosDbDatabaseName
    cosmosDbContainerName: cosmosDbContainerName
    staticAppLocation: staticAppLocation
  }
}

Compile this down to an ARM template with az bicep build, then wire that JSON file’s raw GitHub URL into a standard Deploy to Azure button. Anyone with the button and subscription access gets a Portal wizard prefilled with sane defaults, only needing to type a unique resource name.

az bicep build -f azuredeploy.bicep
Azure Portal running the generated ARM template, prefilled with defaults from the Bicep parameters.
Azure Portal running the generated ARM template, prefilled with defaults from the Bicep parameters.

Deploying the App After Provisioning

Provisioning the resources is only half the job, since a freshly created Static Web App has no code deployed to it yet. Static Web Apps deployment goes through a CI/CD pipeline by design, so the deployment key produced above gets stored as a GitHub secret, and a reusable workflow_call workflow handles the actual deploy step using the official static-web-apps-deploy action.

# app-deploy.yaml
on:
  workflow_call:
    inputs:
      app_location: { type: string, default: app }
      api_location: { type: string, default: api }
      output_location: { type: string, default: wwwroot }
    secrets:
      gh_token: { required: true }
      aswa_token: { required: true }
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - uses: Azure/static-web-apps-deploy@v1
      with:
        azure_static_web_apps_api_token: ${{ secrets.aswa_token }}
        repo_token: ${{ secrets.gh_token }}
        action: upload
        app_location: ${{ inputs.app_location }}
        api_location: ${{ inputs.api_location }}
        output_location: ${{ inputs.output_location }}

Building it as a workflow_call workflow rather than a standalone one is the key design choice, since the same deploy logic needs to run from two different triggers, a normal push to the repo, and a one-time dispatch right after provisioning finishes.

Chaining Provisioning Straight into Deployment

The genuinely tricky part is running both steps back to back in a single autopilot workflow, because the deployment key only exists after provisioning finishes, and GitHub Actions does not let a job read a repository secret that was updated earlier in the same run. A secret update takes effect starting from the next workflow run, not later steps in the current one.

The workaround is to have the provisioning job write the new deployment key into GitHub secrets, then trigger a separate workflow_dispatch event for the deploy step, so it starts a fresh run that can actually see the updated secret.

# main-autopilot.yaml
on:
  workflow_dispatch:
    inputs:
      resourceName: { type: string, required: true }
      location: { type: choice, default: 'Korea Central', options: [Central US, East Asia, Korea Central, West Europe] }
 
jobs:
  call_resource_provisioning:
    uses: ./.github/workflows/resource-provision.yaml
    with:
      resource_name: ${{ github.event.inputs.resourceName }}
      cosdba_location: ${{ github.event.inputs.location }}
    secrets:
      pa_token: ${{ secrets.PA_TOKEN }}
      az_credentials: ${{ secrets.AZURE_CREDENTIALS }}
 
  call_workflow_dispatch:
    needs: call_resource_provisioning
    runs-on: ubuntu-latest
    steps:
    - uses: benc-uk/workflow-dispatch@v1
      with:
        workflow: 'App Deploy Dispatch'
        token: ${{ secrets.PA_TOKEN }}

This needs two credentials set up ahead of time in GitHub secrets, an AZURE_CREDENTIALS service principal for the Azure CLI login inside the provisioning job, and a PA_TOKEN personal access token, needed specifically because updating a repository secret and triggering another workflow both require permissions beyond the default GITHUB_TOKEN.

az ad sp create-for-rbac --name "myApp" --role contributor --sdk-auth
AZURE_CREDENTIALS and PA_TOKEN stored as GitHub Actions secrets, required for the autopilot workflow to run end to end.
AZURE_CREDENTIALS and PA_TOKEN stored as GitHub Actions secrets, required for the autopilot workflow to run end to end.

Once both secrets are in place, running the autopilot workflow from the Actions tab provisions everything and deploys the app in a single click, with no manual copying of connection strings or deployment keys anywhere in between.

The deployed Static Web App running immediately after the autopilot workflow completes.
The deployed Static Web App running immediately after the autopilot workflow completes.

What Still Needs Attention

This gets you a genuinely one-click experience, but there are two rough edges worth knowing before you rely on it for anything beyond a demo. First, the personal access token requirement is a real security consideration, a PAT tied to an individual’s account is broader in scope than most teams are comfortable with for anything beyond a short-lived proof of concept, and it should be rotated and scoped as tightly as GitHub allows if this pattern moves beyond a demo repository.

Second, this workflow is not idempotent in any real sense. Running it twice against the same resource name will attempt to recreate resources that already exist, and depending on the resource type that either fails cleanly or, worse, silently succeeds while leaving configuration in an inconsistent state. Before using this pattern for anything beyond a one-time demo spin-up, add an existence check at the start of the provisioning job, and decide deliberately whether a rerun should update the existing resources or refuse to proceed.

The credential handling here also assumes a trusted, small audience, a service principal with contributor rights at subscription scope is a lot of blast radius for a proof-of-concept repository. If this pattern gets reused for anything more permanent, scope that service principal down to the specific resource group rather than the whole subscription, and consider whether Bicep’s own Deployment Script resource, which can run Azure CLI logic without exposing subscription credentials through GitHub Actions at all, is a tighter fit for the identity boundary you actually want.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading