Enhance your Azure Container App security with Private Endpoints

In this blog post, I will show you how to enable private endpoints for your container apps to improve security without exposing your container apps to the public internet.

The resources are created using Bicep, but it is also possible to use the Azure Portal or Azure CLI.

What is the private endpoint?

Private endpoints have been available in Azure for a while. The private endpoint is a network interface that uses an IP address from your virtual network. You can then use this interface to securely connect to Azure PaaS service that supports Azure Private Link. You can read more about private links from this page. Quite a few Azure services already support private endpoints, and now, finally, we have this also for Azure Container Apps.

Create Container Apps environment

First, we need to create a Container Apps environment. Environment is a secure boundary around container apps and it provides infrastructure resources like CPU, memory and network.

When creating an environment, we need to disable Public Network Access so we can enable a private endpoint.

resource cae 'Microsoft.App/managedEnvironments@2024-10-02-preview' = {
  name: 'cae-${solution}-${env}'
  location: location
  properties: {
    publicNetworkAccess: 'Disabled'
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: log.properties.customerId
        sharedKey: log.listKeys().primarySharedKey
      }
    }
    workloadProfiles: [
      {
        name: 'Consumption'
        workloadProfileType: 'Consumption'
      }
    ]
  }
}

Create private endpoint

Private endpoint is enabled in the Container Apps environment. First, we create a private endpoint using the managedEnvironments groupId. This ID identifies the Azure service where the private endpoint should connect to. In this case, it is an Azure Container Apps environment.

var privateEndpointName = 'pep-cae-${solution}-${env}'

resource privateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = {
  name: privateEndpointName
  location: location
  properties: {
    privateLinkServiceConnections: [
      {
        name: privateEndpointName
        properties: {
          privateLinkServiceId: cae.id
          groupIds: ['managedEnvironments']
        }
      }
    ]
    subnet: {
      id: '${vnet.id}/subnets/snet-private-endpoints'
    }
    customNetworkInterfaceName: privateEndpointName
  }
}

Next, we need to add DNS A record to the private DNS zone so clients can resolve the private endpoint address using the fully qualified domain name (FQDN). The name of the DNS zone should follow the recommended zone names. For Azure Container apps, it is privatelink.{location}.azurecontainerapps.io.

resource privateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = {
  name: 'privatelink.${location}.azurecontainerapps.io'
  location: 'global'
}

resource dnsRecord 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = {
  name: 'default'
  parent: privateEndpoint
  properties: {
    privateDnsZoneConfigs: [
      {
        name: replace(last(split(privateDnsZone.id, '/'))!, '.', '_')
        properties: {
          privateDnsZoneId: privateDnsZone.id
        }
      }
    ]
  }
}

Create Container App

Now we are ready to deploy the container app in the enviroment. By setting the ingress to external, we make the container app available to request over a private link.

resource containerApp 'Microsoft.App/containerApps@2024-10-02-preview' = {
  name: 'ca-${solution}-${env}'
  location: location
  properties: {
    environmentId: cae.id
    configuration: {
      ingress: {
        external: true
        targetPort: 80
      }
    }
    template: {
      containers: [
        {
          name: 'app'
          image: 'mcr.microsoft.com/k8se/quickstart:latest'
        }
      ]
    }
    workloadProfileName: 'Consumption'
  }
}

If we try to browse the container app endpoint from our own machine, we will receive an ERR_CONNECTION_CLOSED response because public access is disabled. The only way to access the application is via a private endpoint. Thus, to verify the deployment and private endpoint connection, we need to create a virtual machine in the virtual network so we can access the container app using the private endpoint.

resource nic 'Microsoft.Network/networkInterfaces@2024-05-01' = {
  name: 'nic-vm-${solution}-${env}'
  location: location
  properties: {
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          subnet: {
            id: '${vnet.id}/subnets/snet-vm'
          }
        }
      }
    ]
  }
}

resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = {
  name: 'vm-${solution}-${env}'
  location: location
  properties: {
    hardwareProfile: {
      vmSize: 'Standard_B2s'
    }
    storageProfile: {
      imageReference: {
        publisher: 'MicrosoftWindowsServer'
        offer: 'WindowsServer'
        sku: '2022-Datacenter'
        version: 'latest'
      }
      osDisk: {
        createOption: 'FromImage'
      }
    }
    osProfile: {
      computerName: 'vm'
      adminUsername: adminUsername
      adminPassword: adminPassword
    }
    networkProfile: {
      networkInterfaces: [
        {
          id: nic.id
        }
      ]
    }
  }
}

After the resources are created, navigate to the Azure Portal and open the created VM. In the Overview page, select Connect via Bastion and enter the VM username and password. Azure Portal will then automatically create a free Developer Bastion host that is used to securely access the VM.

Once connected, open the browser in VM and navigate to the container app endpoint. You should then see the following output.

Complete code example

Below is the complete code example. Use the following commands to deploy the resources to Azure.

az group create --name MyResourceGroup --location northeurope
az deployment group create --resource-group MyResourceGroup --template-file main.bicep
main.bicep
param env string

param solution string

param location string = resourceGroup().location

param adminUsername string = 'vmadmin'

@secure()
param adminPassword string

var vnetAddressPrefix = '10.0.0.0/24'

resource vnet 'Microsoft.Network/virtualNetworks@2024-05-01' = {
  name: 'vnet-${solution}-${env}'
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [vnetAddressPrefix]
    }
    subnets: [
      {
        name: 'snet-private-endpoints'
        properties: {
          addressPrefix: cidrSubnet(vnetAddressPrefix, 27, 0)
        }
      }
      {
        name: 'snet-vm'
        properties: {
          addressPrefix: cidrSubnet(vnetAddressPrefix, 27, 1)
        }
      }
    ]
  }
}

resource privateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' = {
  name: 'privatelink.${location}.azurecontainerapps.io'
  location: 'global'
}

resource privateDnsZoneLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2024-06-01' = {
  parent: privateDnsZone
  name: privateDnsZone.name
  location: 'global'
  properties: {
    registrationEnabled: true
    virtualNetwork: {
      id: vnet.id
    }
  }
}

resource log 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
  name: 'log-${solution}-${env}'
  location: location
  properties: {
    sku: {
      name: 'PerGB2018'
    }
    retentionInDays: 30
  }
}

resource cae 'Microsoft.App/managedEnvironments@2024-10-02-preview' = {
  name: 'cae-${solution}-${env}'
  location: location
  properties: {
    publicNetworkAccess: 'Disabled'
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: log.properties.customerId
        sharedKey: log.listKeys().primarySharedKey
      }
    }
    workloadProfiles: [
      {
        name: 'Consumption'
        workloadProfileType: 'Consumption'
      }
    ]
  }
}

var privateEndpointName = 'pep-cae-${solution}-${env}'

resource privateEndpoint 'Microsoft.Network/privateEndpoints@2024-05-01' = {
  name: privateEndpointName
  location: location
  properties: {
    privateLinkServiceConnections: [
      {
        name: privateEndpointName
        properties: {
          privateLinkServiceId: cae.id
          groupIds: ['managedEnvironments']
        }
      }
    ]
    subnet: {
      id: '${vnet.id}/subnets/snet-private-endpoints'
    }
    customNetworkInterfaceName: privateEndpointName
  }
}

resource dnsRecord 'Microsoft.Network/privateEndpoints/privateDnsZoneGroups@2024-05-01' = {
  name: 'default'
  parent: privateEndpoint
  properties: {
    privateDnsZoneConfigs: [
      {
        name: replace(last(split(privateDnsZone.id, '/'))!, '.', '_')
        properties: {
          privateDnsZoneId: privateDnsZone.id
        }
      }
    ]
  }
}

resource containerApp 'Microsoft.App/containerApps@2024-10-02-preview' = {
  name: 'ca-${solution}-${env}'
  location: location
  properties: {
    environmentId: cae.id
    configuration: {
      ingress: {
        external: true
        targetPort: 80
      }
    }
    template: {
      containers: [
        {
          name: 'app'
          image: 'mcr.microsoft.com/k8se/quickstart:latest'
        }
      ]
    }
    workloadProfileName: 'Consumption'
  }
}

resource nic 'Microsoft.Network/networkInterfaces@2024-05-01' = {
  name: 'nic-vm-${solution}-${env}'
  location: location
  properties: {
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          subnet: {
            id: '${vnet.id}/subnets/snet-vm'
          }
        }
      }
    ]
  }
}

resource vm 'Microsoft.Compute/virtualMachines@2024-07-01' = {
  name: 'vm-${solution}-${env}'
  location: location
  properties: {
    hardwareProfile: {
      vmSize: 'Standard_B2s'
    }
    storageProfile: {
      imageReference: {
        publisher: 'MicrosoftWindowsServer'
        offer: 'WindowsServer'
        sku: '2022-Datacenter'
        version: 'latest'
      }
      osDisk: {
        createOption: 'FromImage'
      }
    }
    osProfile: {
      computerName: 'vm'
      adminUsername: adminUsername
      adminPassword: adminPassword
    }
    networkProfile: {
      networkInterfaces: [
        {
          id: nic.id
        }
      ]
    }
  }
}

Summary

Using private endpoints is an easy way to improve your container app security and make it available only from virtual networks or on-premise networks via VPN or ExpressRoute. Use cases for this are internal Line-of-business (LOB) applications or backend APIs that should only be accessible through an API Gateway.

It is still good to remember that a private endpoint is only for inbound connections. To have network access to the resources in your private network, like an Azure SQL server, you need to create your container app in a virtual network.