Using Github Actions to deploy Blazor to App Service with private endpoint enabled

Chris Montgomery
8 min readJun 1, 2022

I hit a situation at work where I needed to find a way to deploy my Blazor Server app via Github Actions to an app service that has private endpoint enabled on a VNet that only allows P2S VPN connections.

The default workflow generated by VS 2022 worked fine until private endpoint was enabled on the app service, and then this error occurred:

[1] Run azure/webapps-deploy@v2[14](https://github.com/****/******/runs/6625331063?check_suite_focus=true#step:8:15)Package deployment using ZIP Deploy initiated.[15](https://github.com/****/******/runs/6625331063?check_suite_focus=true#step:8:16)Error: Failed to deploy web package to App Service.[16](https://github.com/****/******/runs/6625331063?check_suite_focus=true#step:8:17)Error: Deployment Failed with Error: Error: Failed to deploy web package to App Service.[17](https://github.com/****/******/runs/6625331063?check_suite_focus=true#step:8:18)Ip Forbidden (CODE: 403)[18](https://github.com/****/******/runs/6625331063?check_suite_focus=true#step:8:19)App Service Application URL: [https://******.azurewebsites.net](https://******.azurewebsites.net/)

This is happening because the azure/webapps-deploy action uses the Kudu scm website associated with your app service (your-app.scm.azurewebsites.net) to deploy, and the private endpoint also applies to that website.

While researching solutions, I came across this article which describes a method that boils down to:

  1. Build the app as normal.
  2. Create a temporary storage blob on Azure.
  3. Upload the bundled app to storage blob.
  4. Deploy the web app from the storage blob.
  5. Delete the storage blob.

The method looks promising, but I want to break it down and make sure I understand what’s happening behind the scenes, and break down the changes needed to make to deploy a Blazor Server app.

The Workflow

Here’s the starting workflow, in its entirety:

name: Deploy web app via Storage Accounton:
push:
branches: [ main, master ]
workflow_dispatch:
env:
WEBAPP: your-webapp-name
GROUP: your-resource-group-name
ACCOUNT: name-for-storage-acct # Does not have to exist, this will be created for you
CONTAINER: name-for-storage-container
EXPIRY_TIME: 10 minutes
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 1.8
uses: actions/setup-java@v1
with:
java-version: 1.8
- name: Build with Maven
run: mvn package
- name: Upload artifact for deployment jobs
uses: actions/upload-artifact@v2
with:
name: app
path: target/app.jar
publish:
runs-on: ubuntu-latest
needs: build
steps:
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Download artifact
uses: actions/download-artifact@v2
with:
name: app
- name: Zip the app contents
uses: papeloto/action-zip@v1
with:
files: app.jar
dest: app.zip
- name: Set SAS token expiration
run: echo "expiry=`date -u -d "$EXPIRY_TIME" '+%Y-%m-%dT%H:%MZ'`" >> $GITHUB_ENV
- name: Azure CLI script
uses: azure/CLI@v1
with:
azcliversion: 2.19.1
inlineScript: |
az extension add --name webapp
az storage account create -n $ACCOUNT -g $GROUP -l westus
az storage container create -n $CONTAINER --account-name $ACCOUNT
az storage blob upload -f app.zip --account-name $ACCOUNT -c $CONTAINER -n $ACCOUNT
ZIP_URL=$(az storage blob generate-sas --full-uri --permissions r --expiry ${{ env.expiry }} --account-name $ACCOUNT -c $CONTAINER -n $ACCOUNT | xargs) az webapp deploy --name $WEBAPP --resource-group $GROUP --type zip --src-url $ZIP_URL --async false az storage container delete -n $CONTAINER --account-name $ACCOUNT

We’ll go from top-to-bottom and break down the changes necessary to set this up for ourselves. If you’re in a rush, scroll down to the Final Workflow section and grab the whole final workflow.

Set Environment Variables

env:
WEBAPP: your-webapp-name
GROUP: your-resource-group-name
ACCOUNT: name-for-storage-acct # Does not have to exist, this will be created for you
CONTAINER: name-for-storage-container
EXPIRY_TIME: 10 minutes

Pretty straightforward, but to break it down here’s what you need to fill in:

  • WEBAPP: this should be the name of the app service we're deploying to.
  • GROUP: the resource group our app service is in.
  • ACCOUNT: the name of the storage account the container will be placed in
  • CONTAINER: the name of the container the workflow will create.
  • EXPIRY_TIME: how long the storage blob will give read permissions to our web app. I imagine we might need to bump this up if our deployment starts taking longer than 10 minutes.

Build Job

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 1.8
uses: actions/setup-java@v1
with:
java-version: 1.8
- name: Build with Maven
run: mvn package
- name: Upload artifact for deployment jobs
uses: actions/upload-artifact@v2
with:
name: app
path: target/app.jar

Looking at this section, it is clear we’ll have to change it to work with our Blazor Server app.

To fix this, let’s just rip out the build action from the default VS 2022 generated workflow.

First, add the following to the env section we looked at above:

PUBLISHED_DIR: ./published # artifacts directory  
CONFIGURATION: Release
DOTNET_CORE_VERSION: 6.0.x # replace if your version is different
WORKING_DIRECTORY: . # change to whereever you run `dotnet build` from

and then replace the build job in its entirety with:

build:  
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: ${{ env.DOTNET_CORE_VERSION }}
- name: Restore
run: dotnet restore "${{ env.WORKING_DIRECTORY }}"
- name: Build
run: dotnet build "${{ env.WORKING_DIRECTORY }}" --configuration ${{ env.CONFIGURATION }} --no-restore
- name: Test
run: dotnet test "${{ env.WORKING_DIRECTORY }}" --no-build
- name: Publish
run: dotnet publish "${{ env.WORKING_DIRECTORY }}" --configuration ${{ env.CONFIGURATION }} --no-build --output "${{ env.PUBLISHED_DIR }}"
- name: Upload artifact for deployment jobs
uses: actions/upload-artifact@v2
with:
name: webapp
path: ${{ env.PUBLISHED_DIR }}

Publish Job

publish:
runs-on: ubuntu-latest
needs: build
steps:
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Download artifact
uses: actions/download-artifact@v2
with:
name: app
- name: Zip the app contents
uses: papeloto/action-zip@v1
with:
files: app.jar
dest: app.zip
- name: Set SAS token expiration
run: echo "expiry=`date -u -d "$EXPIRY_TIME" '+%Y-%m-%dT%H:%MZ'`" >> $GITHUB_ENV
- name: Azure CLI script
uses: azure/CLI@v1
with:
azcliversion: 2.19.1
inlineScript: |
az extension add --name webapp
az storage account create -n $ACCOUNT -g $GROUP -l westus
az storage container create -n $CONTAINER --account-name $ACCOUNT
az storage blob upload -f app.zip --account-name $ACCOUNT -c $CONTAINER -n $ACCOUNT
ZIP_URL=$(az storage blob generate-sas --full-uri --permissions r --expiry ${{ env.expiry }} --account-name $ACCOUNT -c $CONTAINER -n $ACCOUNT | xargs) az webapp deploy --name $WEBAPP --resource-group $GROUP --type zip --src-url $ZIP_URL --async false az storage container delete -n $CONTAINER --account-name $ACCOUNT

We’ll make a couple of changes here to support our Blazor app.

In the original article, they were deploying a single .jar file, but we've zipped up a whole directory, so we need to account for that by changing the Download artifact step.

First, we need to zip up the entire ./publish directory that our Blazor app was published to. There's two things to change here: First, let's change the Download artifact job step to download the artifact to a specific directory we can refer to below:

- name: Download artifact  
uses: actions/download-artifact@v2
with:
name: webapp
path: ${{ env.PUBLISHED_DIR }}

Second, replace the Zip the app contents job step with the following:

- name: Zip the app contents  
uses: papeloto/action-zip@v1
with:
files: ${{ env.PUBLISHED_DIR }}
dest: app.zip

This may not be necessary, but I prefer to use the eastus location for the storage blob since that's where the app service is hosted as well. To support this, we add another environment variable:

AZURE_LOCATION: eastus # what location the storage blob will be created in

And then replace the az storage account create line in the Azure CLI script step with the following:

az storage account create   -n $ACCOUNT   -g $GROUP -l ${{ env.AZURE_LOCATION }}

Finally, I found the included az webapp deploy command still ended up trying to hit the app service Kudu scm website, and would therefore fail with the same 403 error I had hit in the beginning. Fear not though, there exists an arcane Azure management REST API that we can use that will deploy without trying to hit the scm website, for now.

Add a new environment variable for your subscription ID:

SUBSCRIPTION: your-subscription-id

Replace the az webapp deploy line with the following:

SITE_URI="https://management.azure.com/subscriptions/${{ env.SUBSCRIPTION }}/resourceGroups/${{ env.GROUP }}/providers/Microsoft.Web/sites/${{ env.WEBAPP }}/extensions/onedeploy?api-version=2020-12-01"  
az rest --method PUT --uri $SITE_URI --body '{ "properties": { "properties": { "packageUri": "'"${ZIP_URL}"'" }, "type": "zip", "ignorestack": false, "clean": true, "restart": false } }'

Final Workflow

With those changes, your workflow should be good to go. As promised, here’s the final workflow with all changes (make sure to fill in the env variables!):

name: Deploy web app via Storage Account  on:  
push:
branches: [ main, master ]
workflow_dispatch:
env:
WEBAPP: your-webapp-name
GROUP: your-resource-group-name
ACCOUNT: name-for-storage-acct # Does not have to exist, this will be created for you
CONTAINER: name-for-storage-container
EXPIRY_TIME: 10 minutes
PUBLISHED_DIR: ./published # artifacts directory
CONFIGURATION: Release
DOTNET_CORE_VERSION: 6.0.x # replace if your version is different
WORKING_DIRECTORY: . # change to whereever you run `dotnet build` from
AZURE_LOCATION: eastus # what location the storage blob will be created in
SUBSCRIPTION: your-subscription-id
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: ${{ env.DOTNET_CORE_VERSION }}
- name: Restore
run: dotnet restore "${{ env.WORKING_DIRECTORY }}"
- name: Build
run: dotnet build "${{ env.WORKING_DIRECTORY }}" --configuration ${{ env.CONFIGURATION }} --no-restore
- name: Test
run: dotnet test "${{ env.WORKING_DIRECTORY }}" --no-build
- name: Publish
run: dotnet publish "${{ env.WORKING_DIRECTORY }}" --configuration ${{ env.CONFIGURATION }} --no-build --output "${{ env.PUBLISHED_DIR }}"
- name: Upload artifact for deployment jobs
uses: actions/upload-artifact@v2
with:
name: webapp
path: ${{ env.PUBLISHED_DIR }}
publish:
runs-on: ubuntu-latest
needs: build
steps:
- name: Azure Login
uses: azure/login@v1
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Download artifact
uses: actions/download-artifact@v2
with:
name: webapp
path: ${{ env.PUBLISHED_DIR }}
- name: Zip the app contents
uses: papeloto/action-zip@v1
with:
files: ${{ env.PUBLISHED_DIR }}
dest: app.zip
- name: Set SAS token expiration
run: echo "expiry=`date -u -d "$EXPIRY_TIME" '+%Y-%m-%dT%H:%MZ'`" >> $GITHUB_ENV
- name: Azure CLI script
uses: azure/CLI@v1
with:
azcliversion: 2.19.1
inlineScript: |
az extension add --name webapp
az storage account create -n $ACCOUNT -g $GROUP -l ${{ env.AZURE_LOCATION }} az storage container create -n $CONTAINER --account-name $ACCOUNT az storage blob upload -f app.zip --account-name $ACCOUNT -c $CONTAINER -n $ACCOUNT
ZIP_URL=$(az storage blob generate-sas --full-uri --permissions r --expiry ${{ env.expiry }} --account-name $ACCOUNT -c $CONTAINER -n $ACCOUNT | xargs)
SITE_URI="https://management.azure.com/subscriptions/${{ env.SUBSCRIPTION }}/resourceGroups/${{ env.GROUP }}/providers/Microsoft.Web/sites/${{ env.WEBAPP }}/extensions/onedeploy?api-version=2020-12-01"
az rest --method PUT --uri $SITE_URI --body '{ "properties": { "properties": { "packageUri": "'"${ZIP_URL}"'" }, "type": "zip", "ignorestack": false, "clean": true, "restart": false } }'
az storage container delete -n $CONTAINER --account-name $ACCOUNT

Create Service Principal

First, for my own edification, what is a Service Principal? A quick dive into Azure’s docs tells us it is an identity that a application uses to access resources. For our purposes, the simplest thing to do here is to create a service principal that has contributor access to the subscription and resource group our app service is in.

To create a service principal, follow this section from Azure’s docs, and make sure to save away the generated JSON object as a secret on your Github repository named AZURE_CREDENTIALS. The credentials will be used in the Azure Login step of the publish job.

Final Touches

This may be specific to the structure of my project, or may be related to some error I’ve made in setting up the workflow that I haven’t found yet, but I also found that I needed to give the app service the explicit startup command for the project when it was deployed through the REST endpoint.

To set this up, go to your App Service -> Settings/Configuration -> General Settings and set the Startup Command value to dotnet YourProject.dll . For me, this was all the changes necessary to get my Blazor Server app deploying via Github Actions to an App Service with private endpoint enabled.

--

--