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.