After my previous fiddling with Azure Container Apps I decided to seeked out how to achive access a private azure network from a GitHub-hosted runner.

How it works

When a workflow is triggered, GitHub creates a runner service and deploys a network interface card (NIC) in the customers private azure network. Once the nic is created an attached, the job is picked up by the runner and started. The runner logs are sent back to GitHub Actions while the runner has access to any resource in the vNet.

Service network communication overview
network communication overview

Who can use this feature

Azure private networking for GitHub-hosted runners requires an organization with the GitHub Team plan. Creation of GitHub-hosted runners is not allowed on trail plans however, so here I’m upgrading for a month.

New GitHub-hosted runner not allowed on trail plans
creation of GitHub-hosted runner not allowed on trail plans
GitHub current plan
current GitHub plan
Upgraded organization
upgraded to Team plan

Preparation

Before GitHub can be configured access into your vNet there is some prereqs:

  1. The resource provider GutHub.Network must be registered on the subscription with the vNet.
  2. Find the database id for your GitHub organization.

Resource Provider

Set-AzContext -Subscription 'landing-zone-demo-001'
Register-AzResourceProvider -ProviderNamespace 'GitHub.Network'
Register resource provider
register resource provider

Find GitHub organization databaseId

To find the databaseId you need a token with minimum read:org permissions.

New GitHub PAT
PAT to fetch GitHub organization databaseId

Once the PAT is ready, use it to query for your databaseId.

$OrganizationName = 'eskill...'
$BearerToken = '<REDACTED>'

$splat = @{
    Uri = 'https://api.github.com/graphql'
    Method = 'POST'
    Authentication = 'OAuth'
    Token = ConvertTo-SecureString -AsPlainText -Force -String $BearerToken
    Body = @{
        "query" = 'query($login: String!) { organization (login: $login) { login databaseId } }'
        "variables" = @{ "login" = $OrganizationName }
    } | ConvertTo-Json
}

(Invoke-RestMethod @splat).data.organization

    login       databaseId
    -----       ----------
    eskill...   123456789

Deploy Azure resources

Now is the time to deploy the azure resources needed. The full example on GitHub deploys all additional resources such as the vNet, subnet, storage account and uami. The following code is a minimal example for the githubNetworkSettings and nsg.

param parLocation string
param parNetworkSettingsName string
param parSubnetId string
param parGitHubDatabaseId string
param nsgName string = 'actions_NSG'

resource githubNetworkSettings 'GitHub.Network/networkSettings@2024-04-02' = {
  name: parNetworkSettingsName
  location: parLocation
  properties: {
    subnetId: parSubnetId
    businessId: parGitHubDatabaseId
  }
}

resource actions_NSG 'Microsoft.Network/networkSecurityGroups@2017-06-01' = {
  name: nsgName
  location: location
  properties: {
    securityRules: [
      {
        name: 'AllowVnetOutBoundOverwrite'
        properties: {
          protocol: 'TCP'
          sourcePortRange: '*'
          destinationPortRange: '443'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: 'VirtualNetwork'
          access: 'Allow'
          priority: 200
          direction: 'Outbound'
          destinationAddressPrefixes: []
        }
      }
      {
        name: 'AllowOutBoundActions'
        properties: {
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '*'
          access: 'Allow'
          priority: 210
          direction: 'Outbound'
          destinationAddressPrefixes: [
            '4.175.114.51/32'
            ...
            '20.84.218.150/32'
          ]
        }
      }
      {
        name: 'AllowOutBoundGitHub'
        properties: {
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '*'
          access: 'Allow'
          priority: 220
          direction: 'Outbound'
          destinationAddressPrefixes: [
            '140.82.112.0/20'
            ...
            '4.208.26.200/32'
          ]
        }
      }
      {
        name: 'AllowStorageOutbound'
        properties: {
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: 'Storage'
          access: 'Allow'
          priority: 230
          direction: 'Outbound'
          destinationAddressPrefixes: []
        }
      }
      {
        name: 'DenyInternetOutBoundOverwrite'
        properties: {
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: 'Internet'
          access: 'Deny'
          priority: 1000
          direction: 'Outbound'
        }
      }
    ]
  }
}

Deploy the bicep template to a subscription, here using powershell.

$splat = @{
    Name                  = -join ('github-nics-{0}' -f (Get-Date -Format 'yyyyMMddTHHMMssffffZ'))[0..63]
    Location              = 'norwayeast'
    TemplateFile          = 'src/bicep/main.bicep'
    TemplateParameterFile = 'main.bicepparam'
    Verbose               = $true
}
Select-AzSubscription -Subscription 'landing-zone-demo-001'
New-AzSubscriptionDeployment @splat

In the azure portal you can check the box for Show hidden types to reveal the networksettings. The resource will have your GitHub databaseId as a property and tag.

Resource group overview show hidden types
github networksettings resourece when show hidden types is checked
GitHub networksettings resource overview
github networksettings resource overview

Create network configuration

Back to GitHub, Azure private networking for GitHub-hosted runners is configurable at the organization level for organization owners. Create a new network configuration and inside it add an Azure Virtual Network. This prompts for details about the azure resource.

New network configuration
new network configuration
New network configuration details
new network configuration - details
New network configuration overview
new network configuration - overview
New network configuration completed
new network configuration - completed

Create runner group

Create a runner group for the new runner, assign the network configuration to the group.

Create runner group
create runner group

Create GitHub-hosted runner

Create a new GitHub-hosted runner and place it in the newly created runner group, as this group is assigned the network configuration.

New GitHub-hosted runner
new GitHub-hosted runner
New GitHub-hosted runner - details
new GitHub-hosted runner - details
New GitHub-hosted runner created
new GitHub-hosted runner created
GitHub-hosted runner details
GitHub-hosted runner - details

Workflow in progress

Now with the setup complete, create a GitHub Action workflow upload a file to a private storage account and trigger it manually. Full example here. The important part is to set runs-on: to be the runner group!

In GitHub you can get an overview of the runner an its jobs, and in the Azure portal you can see new NICs showing up in the resource group.

 1env:
 2  PRIVATE_STORAGE_ACCOUNT_NAME: ${{ vars.PRIVATE_STORAGE_ACCOUNT_NAME }}
 3  PRIVATE_STORAGE_ACCOUNT_CONTAINER_NAME: ${{ vars.PRIVATE_STORAGE_ACCOUNT_CONTAINER_NAME }}
 4
 5jobs:
 6  put-file-in-private-storage:
 7    runs-on: [az-vnet-enabled]
 8
 9    steps:
10      - name: Install pwsh modules
11        shell: pwsh
12        run: 'Install-Module -Name Az.Storage -RequiredVersion 6.1.3 -Force'
13
14      - name: Azure Login
15        uses: azure/login@v2
16        with:
17          client-id: ${{ vars.AZURE_CLIENT_ID }}
18          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
19          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} 
20          enable-AzPSSession: true
21  
22      - name: Put blob in container
23        shell: pwsh
24        run: |
25          $fileName = "testdata-{0}.log" -f (Get-Date).ToString("yyyyMMdd-HH-mm-ss")
26          Set-Content -Path $fileName -Value "example content"
27          $stContext = New-AzStorageContext -StorageAccountName $env:PRIVATE_STORAGE_ACCOUNT_NAME
28          $splat = @{
29              File = $fileName
30              Container = $env:PRIVATE_STORAGE_ACCOUNT_CONTAINER_NAME
31              Context = $stContext
32          }
33          Set-AzStorageBlobContent @splat          
Runner with active jobs
active jobs on runner
Resource group overview
network interfaces created by GitHub

Results

Once the storage account is configured to allow traffic from the runner-subnet, the workflow successfully creates a blob!

File uploaded to storageaccount
file uploaded

Logs

On the logs for the resource group with the runner vNet, there will be events initiated by GitHub Actions API & GitHub CPS Network Service.

Resource group activity log
Resource group - Activity log

Gotchas

Disabled by enterprise administrators

In my enterprice the Hosted compute networking was disabled and I was unable to create new network configuration.

This was solved by heading into the policies for the enterprice and enabling creation of network configurations under the section Hosted compute networking.

Network configurations is disabled by enterprice
hosted compute networking is disabled
Setting to manage creation of network configurations for organizations
allow organizations to create network configurations

Spending limits

My spending limit was set to $0 and this caused the GitHub-hosted runner to never start.

This was solved by upping the spending limit in settings on the organization level.

Runner shutdown due to spending limit caps
spending limit caps prevents runner from starting
Updated spending limit
updated spending limit

Closing words

All files for this post can be found in this repository.

I original did this post in august 2024, but I never got around to publishing it so here it comes in july 2025 instead.