Deploy to Azure Private Endpoints with GitHub Actions
Learn how to deploy your Web App or Function App that is behind private endpoints or has public network access completely disabled
Recently, I had to deploy code to an Azure Web App and an Azure Function App, both of which were using private endpoints behind the scenes.
The Challenge
When using private endpoints, your apps are not accessible from the public internet. This becomes a problem if you have a CI/CD pipeline on GitHub, as the GitHub-hosted runner operates from the internet. When it attempts to deploy code to your Web App or Function App, the request will be blocked with a 403 (Forbidden) error.
Deployment Options
To deploy code to a Web App or Function App behind private endpoints, you have three main options:
1. Self-hosted Agents
You can provision a self-hosted GitHub runner inside your private network (VNet). This allows deployments to be executed from within the same network as your Azure resources.
✅ Pros: Since the agent runs inside your VNet, it has direct access to your Web App or Function App.
❌ Cons: Maintaining self-hosted agents requires ongoing management, including updates, patching, and ensuring high availability.
2. Toggling Networking Settings
Another approach is to temporarily enable public access for your Web App or Function App before deployment. Once the deployment is complete, you can disable public access again.
✅ Pros: Simple to implement if you have the necessary permissions.
❌ Cons: If the pipeline fails or is interrupted, your apps could remain publicly accessible, creating a potential security risk.
3. Azure REST API (One Deploy)
Azure provides a REST API-based deployment extension called One Deploy. This allows you to push code to your Web App or Function App without needing to expose it to the internet.
✅ Pros: No need to manage infrastructure or expose services publicly.
❌ Cons: Requires familiarity with Azure REST APIs and authentication setup.
In this post, we'll dive deeper into the third option—using the Azure REST API (One Deploy) to deploy your app securely.
Let’s say we are deploying our app inside a resource group called rg-myapp
Create resource group rg-myapp
Create application registration (You will use this to authenticate later)
Create secret
You will need to create a secret that you will use later. Make sure you save it for later use as you will only see it once.
Add the application as a Contributor
to the resource group
This will allow you to use this application to authenticate against Azure and deploy your app later.
Create a basic Function App
Disable public network access
Once the deployment is complete, ensure that public network access is disabled. This will make your Function App inaccessible from the public internet, which is the desired setup for this demo.
In a real-world scenario, you would typically use private endpoints, but I won’t go into those details here. Disabling public access is sufficient, and whether or not you use private endpoints does not affect the deployment process—it works the same way in both cases.
Create deployments container inside Function App’s own storage account.
We’ll need a Storage Account and a container to temporarily store our application before deployment. For this demo, I'll use the Storage Account that comes with our Function App.
Now that we have everything set up, we can proceed with deploying our app using GitHub Actions.
To do this, we need to create a secret that stores the credentials required to authenticate with Azure. Remember the application registration we created earlier? We'll use its details for authentication.
Prepare AZURE_CREDENTIALS secret
Today, we’ll be using the Service Principal approach to authenticate with Azure. If you prefer to use OpenID Connect, check out my other post, where I demonstrate authentication using Federated Identity.
You need to create a secret in GitHub that contains the following:
{
"clientId": "<Client ID OF APP REGISTRATION>",
"clientSecret": "<Client Secret THAT WE CREATED AS FIRST STEP>",
"subscriptionId": "<Subscription ID>",
"tenantId": "<Tenant ID>"
}
You can get all of this info from your Azure portal.
Create GitHub environment and put the secret in there
Now it's time to set up our GitHub Actions workflow. First, we'll create an environment and store our secrets there. I've also created a basic Azure Functions app inside a branch named privatefunc
, which I'll use for deployment. Here’s how it looks on my end:
Now you need to use yml
script to deploy. Here’s what I’m using
name: Build, publish, and deploy PrivateFunc
on:
push:
paths:
- "src/PrivateFunc/**" # Replace with your function app path
branches:
- "private-endpoints-cicd" # Replace with your branch name
env:
BUILD_CONFIGURATION: Release
DOTNET_VERSION: 8.0
ARTIFACT_NAME: PrivateFunc
APP_SERVICE_NAME: my-func-app123 # Replace with your function app name in Azure
APP_PATH: src/PrivateFunc
APP_PUBLISH_PATH: publish/PrivateFunc
STORAGE_ACCOUNT: rgmyapp840a # Replace with your function app storage account name
CONTAINER: deployments # Replace with your deployment container name
RESOURCE_GROUP: rg-myapp # Replace with your resource group name
jobs:
build:
name: Build PrivateFunc
runs-on: ubuntu-latest
environment:
name: development
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: dotnet build and publish
run: |
dotnet restore ${{ env.APP_PATH }}
dotnet build ${{ env.APP_PATH }} -c ${{ env.BUILD_CONFIGURATION }}
dotnet publish ${{ env.APP_PATH }} -c ${{ env.BUILD_CONFIGURATION }} --property:PublishDir=${{ env.APP_PUBLISH_PATH }} --no-self-contained
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
path: ${{ github.workspace }}/${{ env.APP_PATH}}/${{ env.APP_PUBLISH_PATH }}
if-no-files-found: error
include-hidden-files: true
deploy:
name: Deploy PrivateFunc
needs: build
runs-on: ubuntu-latest
environment:
name: development
steps:
- uses: azure/login@v2
with:
creds: ${{ secrets.AZURE_CREDENTIALS }}
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
path: ./${{ env.ARTIFACT_NAME }}
# This step is especially important if you're deploying to Function Apps running on Windows !!! (Will do a post about it)
- name: Create ZIP File from Artifact
run: |
cd ${{ env.ARTIFACT_NAME }} && zip -r ../app.zip . .azurefunctions
shell: bash
- name: Extract Subscription ID
run: |
echo "SUBSCRIPTION_ID=$(echo '${{ secrets.AZURE_CREDENTIALS }}' | jq -r '.subscriptionId')" >> $GITHUB_ENV
- name: Upload Application to Azure Storage
run: |
EXPIRY=$(date -u -d "1 day" '+%Y-%m-%dT%H:%M:%SZ')
az storage blob upload \
--account-name ${{ env.STORAGE_ACCOUNT }} \
-c ${{ env.CONTAINER }} \
-f app.zip \
--overwrite true
APP_URL=$(az storage blob generate-sas \
--full-uri \
--permissions r \
--expiry $EXPIRY \
--account-name ${{ env.STORAGE_ACCOUNT }} \
-c ${{ env.CONTAINER }} \
-n app.zip | xargs)
echo "APP_URL=${APP_URL}" >> $GITHUB_ENV
echo "Generated Application URL: $APP_URL"
- name: Deploy Application
run: |
az webapp deploy --name ${{env.APP_SERVICE_NAME}} --resource-group ${{ env.RESOURCE_GROUP }} --type zip --src-url $APP_URL --async false
# OR if you're deploying to Function App you can use the following:
# az functionapp deploy --name ${{env.FUNC_APP_NAME}} --resource-group ${{ env.RESOURCE_GROUP }} --type zip --src-url $APP_URL --async false
If you've followed every step correctly, you should be able to deploy your code without any issues. This issue is widespread, and I've seen many developers struggling to find a reliable solution. If you find this blog post helpful, please consider sharing it wherever you see others facing the same challenge—I’ll be doing the same. Let's help make this solution more accessible to everyone who needs it.
Thank you for reading, and I hope this guide was helpful!