1. Introduction

As organisations modernize their applications, containerization has become an increasingly popular choice. Azure Container Apps is a platform-as-a-service offering that simplifies the deployment and management of containerized applications without the need to manage the underlying infrastructure.

However, deploying a container app to the public internet without any security measures can leave it vulnerable to attacks. This is where Azure API Management (APIM) and Azure Application Gateway come in handy. In this blog post, we'll explore how to deploy an Azure container app behind APIM and Application Gateway within a virtual network, providing enhanced security and scalability for your containerized applications.

2. Architecture Overview

the architecture

The architecture consists of three subnets within a virtual network: one for the container apps, one for the Azure API Management service, and one for the Azure Application Gateway. All three subnets are secured with a network security group. A private DNS zone is used inside the virtual network to resolve the domain names of the container apps and API Management service. Traffic from the public internet is routed through the Application Gateway, which acts as a frontend for the container apps.

In summary, the architecture allows for the secure deployment of container apps in a virtual network, with the Azure API Management service and the Azure Application Gateway providing additional layers of security and scalability.

3. Benefits of the Architecture

By running Azure Container Apps behind Azure API Management service and Application Gateway within a virtual network, this architecture provides enhanced security for your containerized applications. Since the container application and the API Management service are each located in their own subnets within the virtual network, they are not directly accessible from the public internet. This is made possible by configuring Network Security Groups (NSGs) with appropriate rules. The ingress point to the virtual network is the Application Gateway, which contains a Web Application Firewall that can help protect web applications from common exploits and vulnerabilities.

In addition to security benefits, this architecture also offers scalability and streamlined deployment and management of containerized applications. Azure Container Apps, which are powered by an Azure Kubernetes cluster under the hood, provide auto-scaling and blue-green deployments out of the box, and they abstract away the complexity of managing Kubernetes clusters. Both Azure API Management and Application Gateway also support auto-scaling, making the entire solution highly scalable.

By leveraging these benefits, organisations can reduce costs and increase business agility by rapidly deploying and scaling containerized applications with minimal management overhead.

4. Configuring Azure Container Apps with Azure API Management service and Application Gateway

4.1 The Application

In this example we will use a very simple API with one HTTP GET endpoint. Files are listed below,

ping-app.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net7.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <RootNamespace>ping_app</RootNamespace>
    <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.0" />
    <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.17.0" />
    <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
  </ItemGroup>

</Project>
Program.cs
var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services.AddControllers();
// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure the HTTP request pipeline.
app.UseSwagger();
app.UseSwaggerUI();

app.UseAuthorization();

app.MapControllers();

app.Run();
appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*"
}
Dockerfile
FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base
WORKDIR /app
EXPOSE 80

FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build
WORKDIR /src
COPY ["ping-app.csproj", "."]
RUN dotnet restore "ping-app.csproj"
COPY . .
RUN dotnet build "ping-app.csproj" -c Release -o /app/build

FROM build AS publish
RUN dotnet publish "ping-app.csproj" -c Release -o /app/publish /p:UseAppHost=false

FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "ping-app.dll"]
Controllers\PingController.cs
using Microsoft.AspNetCore.Mvc;

namespace ping_app.Controllers
{
    [ApiController]
    [Route("[controller]")]
    public class PingController : ControllerBase
    {
        private readonly ILogger<PingController> _logger;

        public PingController(ILogger<PingController> logger)
        {
            _logger = logger;
        }

        [HttpGet(Name = "ping")]
        public bool Get()
        {
            return true;
        }
    }
}

After setting up the required files, you can run the project and verify its functionality by accessing the endpoint http://<base url>/ping, which should return an HTTP 200 response with the content "true". Additionally, the swagger document can be accessed via http://<base url>/swagger/v1/swagger.json.

For this example, we have utilized WSL along with Podman to build the Docker container. To build the Docker image, run the following command from the WSL terminal with Podman installed. Any docker cli compatible software can be used for this exercise.

podman build . -t ping-app

when you run the podman images command, an image named localhost/ping-app should appear on the images list.

4.2 Certificates

We have used two self-signed certificates for this exercise. One is a root certificate that serves as the Certificate Authority (CA), and the other is a wildcard certificate that can be used for both the container apps and the API Management service. To generate these certificates, we used openssl. Alternatively, WSL (Windows Subsystem for Linux) can be used to generate the certificates. In order to use WSL, simply open up a powershell window with Administrator permissions, and enter into the WSL prompt. With WSL the config file can be skipped and can go directly to run the commands to generate certificates.

First, extract the binary distribution of openssl and create a config file as shown below:

openssl.cnf
oid_section = OIDs

[ OIDs ]
certificateTemplateName = 1.3.6.1.4.1.311.20.2
caVersion = 1.3.6.1.4.1.311.21.1

[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = yes

[ req_distinguished_name ]
commonName = Common Name
subjectAltName = Alt Name

[ v3_req ]
subjectKeyIdentifier = hash
basicConstraints = critical, CA:true
keyUsage = digitalSignature, cRLSign, keyCertSign
certificateTemplateName = ASN1:PRINTABLESTRING:CA
caVersion = ASN1:INTEGER:0

Then add a path variable OPENSSL_CONF pointing to this config file.

First create the root certificate. For this certificate the common name is used as internal for this exercise.

#1. First generate the root key.
.\openssl.exe genrsa -des3 -out root-ca.key 4096
#2. Create and self sign the root certificate. When prompted enter common name as "internal".
.\openssl.exe req -x509 -new -nodes -key .\root-ca.key -sha256 -days 1024 -out .\root-cert.crt
#3. Create a PFX file
.\openssl.exe pkcs12 -export -out .\root-cert.pfx -inkey .\root-ca.key -in .\root-cert.crt

Second step is to create the signing request and generate the wild card certificate. For this step, the common name is used as *.vnet.internal.

#1. Create the certificate request. Use the common name as "*.vnet.internal".
.\openssl.exe req -new -key .\root-ca.key -out .\vnet-internal-cert.csr
#2. Generate the certificate using the root certificate.
.\openssl.exe x509 -req -in .\vnet-internal-cert.csr -CA .\root-cert.crt -CAkey .\root-ca.key -CAcreateserial -out .\vnet-internal-cert.crt -days 500 -sha256
#3. Export the wildcard certificate.
.\openssl.exe pkcs12 -export -out .\vnet-internal-cert.pfx -inkey .\root-ca.key -in .\vnet-internal-cert.crt

4.3 The Infrastructure

In this exercise, we have used bicep to build the Azure infrastructure. Ideally, the infrastructure deployment should use a CD (continuous delivery) pipeline. However, in this instance, we would use manual deployment to keep this blog post as short as possible.

First of all, create a new resource group.

We used three templates to provision the infrastructure. The first template deploys the KeyVault and the managed identity, which are required for services such as the API Management Service and Container App Environment that rely on certificates stored in the KeyVault. The second template creates the Virtual Network, Subnets, Network Security Group, Private DNS, and Application Gateway. Finally, the third template is used to deploy applications to the environment, and it can be duplicated and used to deploy different applications.

The first template generates prerequisites to build the infrastructure. This template creates the following resources:

  • KeyVault
  • Azure Container Registry
  • Managed Identity
  • Log Analytics Workspace

The following files are used in this template:

acr.bicep
@minLength(5)
@maxLength(50)
@description('Name of the azure container registry (must be globally unique)')
param acrName string
param location string

@allowed([
  'Basic'
  'Standard'
  'Premium'
])
param acrSku string = 'Basic'

// azure container registry
resource acr 'Microsoft.ContainerRegistry/registries@2021-09-01' = {
  name: acrName
  location: location
  sku: {
    name: acrSku
  }
  properties: {
    adminUserEnabled: true
  }
}

output acrLoginServer string = acr.properties.loginServer
mi.bicep
param location string
param miName string 

resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2022-01-31-preview' = {
  name: miName
  location: location
}

output identityName string = mi.name
kv.bicep
param keyVaultName string
param location string 
param identityName string

resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2022-01-31-preview' existing = {
  name: identityName
}

resource kv 'Microsoft.KeyVault/vaults@2022-07-01' = {
  name: keyVaultName
  location: location
  properties: {
    enabledForTemplateDeployment: true
    tenantId: subscription().tenantId
    accessPolicies: [
      {
        objectId: mi.properties.principalId
        tenantId: subscription().tenantId
        permissions: {
          keys: [
            'list'
            'get'
          ]
          secrets: [
            'list'
            'get'
          ]
        }
      }
    ]
    sku: {
      name: 'standard'
      family: 'A'
    }
    networkAcls: {
      defaultAction: 'Allow'
      bypass: 'AzureServices'
    }
  }
}
diag.bicep
param location string 
param logAnalyticsWorkspaceName string

resource logAnalyticsWorkspace'Microsoft.OperationalInsights/workspaces@2020-03-01-preview' = {
  name: logAnalyticsWorkspaceName
  location: location
  properties: any({
    retentionInDays: 30
    features: {
      searchVersion: 1
    }
    sku: {
      name: 'PerGB2018'
    }
  })
}

output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id
prerequisites.bicep
param location string = resourceGroup().location
param environmentName string = 'test'
param keyVaultName string = '${environmentName}-001-kv'
param miName string = '${environmentName}-mi'
param logAnalyticsWorkspaceName string = '${environmentName}-logs'
var acrName = '${environmentName}0apps0acr'

module acrModule 'acr.bicep' = {
  name: 'acrDeploy'
  params: {
    acrName: acrName
    location: location
  }
}

module miModule 'mi.bicep' = {
  name: 'miDeploy'
  params: {
    miName: miName
    location: location
  }
}

module kvModule 'kv.bicep' = {
  name: 'kvDeploy'
  params: {
    keyVaultName: keyVaultName
    location: location
    identityName: miModule.outputs.identityName
  }
  dependsOn:[miModule]
}

module diagModule 'diag.bicep' = {
  name: 'diagDeploy'
  params: {
    logAnalyticsWorkspaceName: logAnalyticsWorkspaceName
    location: location
  }
}

Note:you may need to change the KeyVault name in order to do a successful deployment. Azure would complain about the requirement of unique global name

You can run the command in order to generate the ARM template,

az bicep build --file .\prerequisites.bicep

Use a new "Template Deployment" to deploy the resulting ARM template (prerequisites.json) to the resource group.

Once the deployment is completed, modify the key vault permissions to enable writing and add the following secrets to the key vault.

  • upload the root-cert.pfx with the key 'root-cert'.
  • upload the vnet-internal-cert.pfx certificate with the key 'vnet-internal-cert'

Alternatively, you can directly deploy the Bicep template to the resource group by running,

az deployment group create --name prerequisites --resource-group <resource group name> 
  --template-file .\prerequisites.bicep

Obtain the password to the container registry and use the following command to push the docker image to the registry.

podman push ping-app:latest  <container registry name>.azurecr.io/ping-app:latest --creds=<container registry name>:<registry password>  

The second template creates the following resources,

  • Virtual Network, Subnets and Network Security Group
  • Container App Environment
  • API Management
  • Private DNS
  • Application Gateway

Following files are used in this template:

vnet.bicep
param location string
param virtualNetworkName string
param appgwSubnetName string
param apimSubnetName string
param appsvcSubnetName string

resource nsg 'Microsoft.Network/networkSecurityGroups@2022-01-01' = {
  name: '${virtualNetworkName}-nsg'
  location: location
  properties: {
    securityRules: [
      {
        name: 'appgw-to-apim'
        properties: {
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '10.0.0.0/24'
          destinationAddressPrefix: '10.0.1.0/24'
          access: 'Allow'
          priority: 170
          direction: 'Inbound'
        }
      }
      {
        name: 'apim-to-appsvc'
        properties: {
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '10.0.1.0/24'
          destinationAddressPrefix: '10.0.2.0/23'
          access: 'Allow'
          priority: 180
          direction: 'Inbound'
        }
      }
      {
        name: 'container-to-appsvc'
        properties: {
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '10.0.1.0/24'
          destinationAddressPrefix: '10.0.2.0/23'
          access: 'Allow'
          priority: 190
          direction: 'Inbound'
        }
      }
      {
        name: 'allow-internet-traffic'
        properties:{
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRange: '80'
          sourceAddressPrefix: 'Internet'
          destinationAddressPrefix: '10.0.0.0/24'
          access: 'Allow'
          priority: 200
          direction: 'Inbound'
        } 
      }
      {
        name: 'allowCommunicationBetweenInfrastructuresubnet'
        properties: {
          priority: 210
          direction: 'Inbound'
          access: 'Allow'
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '10.0.2.0/23'
          destinationAddressPrefix: '10.0.2.0/23'
        }
      }
      {
        name: 'allowAzureLoadBalancerCommunication'
        properties: {
          priority: 220
          direction: 'Inbound'
          access: 'Allow'
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: 'AzureLoadBalancer'
          destinationAddressPrefix: '*'
        }
      }
      {
        name: 'allowAKSSecureConnectionInternalNodeControlPlaneUDP'
        properties: {
          priority: 230
          direction: 'Inbound'
          access: 'Allow'
          protocol: 'Udp'
          sourcePortRange: '*'
          destinationPortRange: '1194'
          sourceAddressPrefix: 'AzureCloud.${location}'
          destinationAddressPrefix: '*'
        }
      }
      {
        name: 'allowAKSSecureConnectionInternalNodeControlPlaneTCP'
        properties: {
          priority: 240
          direction: 'Inbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRange: '9000'
          sourceAddressPrefix: 'AzureCloud.${location}'
          destinationAddressPrefix: '*'
        }
      }
      {
        name: 'appGwInfraCommunication'
        properties: {
          priority: 250
          direction: 'Inbound'
          access: 'Allow'
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '65200-65535'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '*'
        }
      }
      {
        name: 'allowOutboundCallstoAzureMonitor'
        properties: {
          priority: 250
          direction: 'Outbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRange: '443'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: 'AzureMonitor'
        }
      }
      {
        name: 'allowAllOutboundOnPort443'
        properties: {
          priority: 260
          direction: 'Outbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRange: '443'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '*'
        }
      }
      {
        name: 'allowNTPServer'
        properties: {
          priority: 270
          direction: 'Outbound'
          access: 'Allow'
          protocol: 'Udp'
          sourcePortRange: '*'
          destinationPortRange: '123'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '*'
        }
      }
      {
        name: 'allowContainerAppsControlPlaneTCP'
        properties: {
          priority: 280
          direction: 'Outbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRange: '5671'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '*'
        }
      }
      {
        name: 'allowContainerAppsControlPlaneTCP2'
        properties: {
          priority: 290
          direction: 'Outbound'
          access: 'Allow'
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRange: '5672'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '*'
        }
      }
      {
        name: 'allowCommsBetweenSubnet'
        properties: {
          priority: 300
          direction: 'Outbound'
          access: 'Allow'
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '10.0.2.0/23'
          destinationAddressPrefix: '10.0.2.0/23'
        }
      }
      {
        name: 'deny-apim-from-others'
        properties: {
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '10.0.1.0/24'
          access: 'Allow' //change to deny later
          priority: 310
          direction: 'Inbound'
        }
      }
      {
        name: 'deny-appsvc-from-others'
        properties: {
          protocol: '*'
          sourcePortRange: '*'
          destinationPortRange: '*'
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '10.0.2.0/23'
          access: 'Deny'
          priority: 320
          direction: 'Inbound'
        }
      }
    ]
  }
}

resource virtualNetwork 'Microsoft.Network/virtualNetworks@2019-11-01' = {
  name: virtualNetworkName
  location: location
  properties: {
    addressSpace: {
      addressPrefixes: [
        '10.0.0.0/16'
      ]
    }
    subnets: [
      {
        name: appgwSubnetName
        properties: {
          addressPrefix: '10.0.0.0/24'
          networkSecurityGroup: {
            id: nsg.id
          }
        }
      }
      {
        name: apimSubnetName
        properties: {
          addressPrefix: '10.0.1.0/24'
          networkSecurityGroup: {
            id: nsg.id
          }
          serviceEndpoints: [
            {
              service: 'Microsoft.Storage'
            }
            {
              service: 'Microsoft.Sql'
            }
            {
              service: 'Microsoft.EventHub'
            }
          ]
        }
      }
      {
        name: appsvcSubnetName
        properties: {
          addressPrefix: '10.0.2.0/23'
          networkSecurityGroup: {
            id: nsg.id
          }
        }
      }
    ]
  }
}
appsvc-env.bicep
param environmentName string
param location string 
param virtualNetworkName string
param subnetName string
param logAnalyticsWorkspaceId string
param dnsName string
@secure()
param wildcardCertificateBase64 string
param deployAppEnv bool

resource subnet 'Microsoft.Network/virtualNetworks/subnets@2022-07-01' existing = {
  name: '${virtualNetworkName}/${subnetName}'
}

resource appEnvironment 'Microsoft.App/managedEnvironments@2022-06-01-preview' = if (deployAppEnv) {
  name: '${environmentName}-env'
  location: location
  sku: {
    name: 'Consumption'
  }
  properties: {
    appLogsConfiguration: {
      destination: 'log-analytics'
      logAnalyticsConfiguration: {
        customerId: reference(logAnalyticsWorkspaceId, '2020-03-01-preview').customerId
        sharedKey: listKeys(logAnalyticsWorkspaceId, '2020-03-01-preview').primarySharedKey
      }
    }
    customDomainConfiguration: {
      dnsSuffix: dnsName
      certificateValue: wildcardCertificateBase64
      certificatePassword: ''
    }
    vnetConfiguration: {
      internal: true
      infrastructureSubnetId: subnet.id
      dockerBridgeCidr: '10.0.4.0/24'
      platformReservedCidr: '10.0.5.0/24'
      platformReservedDnsIP: '10.0.5.2'
    }
    zoneRedundant: false
  }
}

var defaultDomain = appEnvironment.properties.defaultDomain
var staticIp = appEnvironment.properties.staticIp
output defaultDomain string = defaultDomain
output staticIp string = staticIp
apim.bicep
param apimName string
param location string
param virtualNetworkName string
param subnetName string
param logAnalyticsWorkspaceId string
param dnsName string
param identityName string
param keyVaultName string
@secure()
param rootCertificateBase64 string
param deployApim bool

resource subnet 'Microsoft.Network/virtualNetworks/subnets@2022-07-01' existing = {
  name: '${virtualNetworkName}/${subnetName}'
}

resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2022-01-31-preview' existing = {
  name: identityName
}

resource apim 'Microsoft.ApiManagement/service@2022-04-01-preview' = if (deployApim) {
  name: apimName
  location: location
  sku: {
    name: 'Developer'
    capacity: 1
  }
  identity: {
    type: 'UserAssigned'
    userAssignedIdentities: {
      '${mi.id}' : {}   
    }
  }
  properties: {
    publisherEmail: 'adipa@example.com'
    publisherName: 'adipa'
    virtualNetworkConfiguration: {
      subnetResourceId: subnet.id     
    }
    certificates: [
      {
        storeName: 'Root'
        encodedCertificate: rootCertificateBase64
        certificatePassword: ''
      }
    ]
    hostnameConfigurations: [
      {
        type:'Proxy'
        hostName: 'apim.${dnsName}'
        keyVaultId: 'https://${keyVaultName}${environment().suffixes.keyvaultDns}/secrets/vnet-internal-cert'
        certificatePassword: ''
        identityClientId : mi.properties.clientId
        negotiateClientCertificate:false
        defaultSslBinding: true 
      }
    ]
    customProperties:{
      'Microsoft.WindowsAzure.ApiManagement.Gateway.Protocols.Server.Http2': 'True'
     }
    virtualNetworkType: 'Internal'
  }
}

resource diagSettings 'microsoft.insights/diagnosticSettings@2017-05-01-preview' = {
  name: 'writeToLogAnalytics'
  scope: apim
  properties:{
   logAnalyticsDestinationType: 'Dedicated'
   workspaceId : logAnalyticsWorkspaceId
    logs:[
      {
        category: 'GatewayLogs'
        enabled:true
        retentionPolicy:{
          enabled:false
          days: 0
        }
      }         
    ]
    metrics:[
      {
        category: 'AllMetrics'
        enabled:true
        timeGrain: 'PT1M'
        retentionPolicy:{
         enabled:false
         days: 0
       }
      }
    ]
  }
 }

var apimUrl = apim.properties.gatewayUrl
output apimHost string = replace(replace(apimUrl, 'https://', ''), 'http://', '')

output apimPrivateIp string = apim.properties.privateIPAddresses[0]
dns.bicep
param virtualNetworkName string
param apimPrivateIp string
param dnsName string

resource vnet 'Microsoft.Network/virtualNetworks@2019-11-01' existing = {
  name: virtualNetworkName
}

resource appEnvDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' = {
  name: dnsName
  location:'global'
  properties: {}
}

resource appEnvDnsVnetLink 'Microsoft.Network/privateDnsZones/virtualNetworkLinks@2020-06-01' = {
  name: '${virtualNetworkName}-${dnsName}-link'
  location:'global'
  parent: appEnvDnsZone
  properties: {
    virtualNetwork: {
      id: vnet.id
    }
    registrationEnabled: true
  }
}

resource apimARecord 'Microsoft.Network/privateDnsZones/A@2018-09-01' = {
  name: 'apim'
  parent: appEnvDnsZone
  properties: {
    ttl: 60
    aRecords: [
      {
        ipv4Address: apimPrivateIp
      }
    ]
  }
}
appgw.bicep
param environmentName string
param appGatewayName string
param location string
param virtualNetworkName string
param subnetName string
param logAnalyticsWorkspaceId string
param dnsName string
param identityName string
param keyVaultName string
param deployAppGw bool

resource mi 'Microsoft.ManagedIdentity/userAssignedIdentities@2022-01-31-preview' existing = {
  name: identityName
}

resource subnet 'Microsoft.Network/virtualNetworks/subnets@2022-07-01' existing = {
  name: '${virtualNetworkName}/${subnetName}'
}

resource publicIp 'Microsoft.Network/publicIPAddresses@2022-07-01' = {
  name: '${environmentName}-appgw-public-ip'
  location: location
  sku: {
    name: 'Standard'
  }
  properties: {
    publicIPAllocationMethod:'Static'
  }
}

var appGwId = resourceId('Microsoft.Network/applicationGateways', appGatewayName)

resource appgw 'Microsoft.Network/applicationGateways@2022-07-01' = if (deployAppGw) {
  name: appGatewayName
  location: location
  identity:{
    type:'UserAssigned'
    userAssignedIdentities:{
      '${mi.id}' : {}
    }
  }
  properties:{
    sku:{
      name: 'WAF_v2'
      tier: 'WAF_v2'
    }
    enableHttp2:true
    sslPolicy:{
      policyType:'Predefined'
      policyName:'AppGwSslPolicy20170401S'
    }
    autoscaleConfiguration:{
      minCapacity: 1
      maxCapacity: 2
    }
    webApplicationFirewallConfiguration:{
      enabled:true
      firewallMode:'Detection'
      ruleSetType: 'OWASP'
      ruleSetVersion: '3.1'
      disabledRuleGroups:[
        {
          ruleGroupName: 'REQUEST-920-PROTOCOL-ENFORCEMENT'
          rules:[
            920320
          ]
        }
      ]
      exclusions:[
        
      ]
      requestBodyCheck:false
    }
    trustedRootCertificates:[{
      name: 'root_cert_internaldomain'
      properties: {
        keyVaultSecretId: 'https://${keyVaultName}${environment().suffixes.keyvaultDns}/secrets/root-cert'
      }
    }
    ]
    probes:[
      {
        name: 'apimgw-probe'
        properties:{
          pickHostNameFromBackendHttpSettings:true
          interval:30
          timeout:30
          path: '/status-0123456789abcdef'
          protocol:'Https'
          unhealthyThreshold:3
          match:{
            statusCodes:[
              '200-399'
            ]
          }
        }
      }            
    ]
    gatewayIPConfigurations:[
      {
        name: 'appgw-ip-config'
        properties:{
          subnet:{
            id: subnet.id
          }
        }
      }
    ]
    frontendIPConfigurations:[
      { 
        name:'appgw-public-frontend-ip'
        properties:{
          publicIPAddress:{
            id: publicIp.id
          }
        }
      }
    ]
    frontendPorts:[
      {
        name: 'port_80'
        properties:{
          port: 80
        }
      }
    ]
    backendAddressPools:[
      { 
        name: 'backend-apigw'
        properties:{
          backendAddresses:[
            {
              fqdn: 'apim.${dnsName}'
            }
          ]
        }
      }
    ]
    backendHttpSettingsCollection:[
     {
       name: 'apim_gw_httpssetting'
       properties:{
         port: 443
         protocol:'Https'
         cookieBasedAffinity:'Disabled'
         requestTimeout: 120
         connectionDraining:{
           enabled:true
           drainTimeoutInSec: 20
         }
         pickHostNameFromBackendAddress:true
         probe: {id : '${appGwId}/probes/apimgw-probe'}
         trustedRootCertificates:[
          {id :'${appGwId}/trustedRootCertificates/root_cert_internaldomain'}
         ]
       }
     } 
    ]
    httpListeners:[
      {
        name: 'apigw-http-listener'
        properties:{
          protocol:'Http'
          frontendIPConfiguration:  {id :'${appGwId}/frontendIPConfigurations/appgw-public-frontend-ip'}
          frontendPort:  {id :'${appGwId}/frontendPorts/port_80'}
        }
      }          
    ]
    rewriteRuleSets:[
      {
        name: 'default-rewrite-rules'
        properties:{
          rewriteRules:[
            {
              ruleSequence : 1000
              conditions:[
              ]
              name: 'HSTS header injection'
              actionSet:{
                requestHeaderConfigurations:[
                  
                ]
                responseHeaderConfigurations:[
                  {
                    headerName: 'Strict-Transport-Security'
                    headerValue: 'max-age=31536000; includeSubDomains'
                  }
                ]
              }
            }
          ]
        }
      }
    ]
    requestRoutingRules:[
      {
        name: 'routing-apigw'
        properties:{
          priority: 1
          ruleType:'Basic'
          httpListener:  {id :'${appGwId}/httpListeners/apigw-http-listener'}
          backendAddressPool:  {id :'${appGwId}/backendAddressPools/backend-apigw'}
          backendHttpSettings:  {id :'${appGwId}/backendHttpSettingsCollection/apim_gw_httpssetting'}
          rewriteRuleSet:  {id :'${appGwId}/rewriteRuleSets/default-rewrite-rules'}
        }
      }
    ]
  }
}

resource diagSettings 'microsoft.insights/diagnosticSettings@2017-05-01-preview' = {
 name: 'writeToLogAnalytics'
 scope: appgw
 properties:{
  workspaceId : logAnalyticsWorkspaceId
   metrics:[
     {
       enabled:true
       timeGrain: 'PT1M'
       retentionPolicy:{
        enabled:true
        days: 20
      }
     }
   ]
 }
}

output appGwUrl string = 'http://${publicIp.properties.ipAddress}'
infra.bicep
param location string = resourceGroup().location
param environmentName string = 'test'
param appgwSubnetName string = 'appgw-subnet'
param apimSubnetName string = 'apim-subnet'
param appsvcSubnetName string = 'appsvc-subnet'
param keyVaultName string = '${environmentName}001-kv'
param miName string = '${environmentName}-mi'
param logAnalyticsWorkspaceName string = '${environmentName}-logs'

var virtualNetworkName = '${environmentName}-vnet'
var apimName = '${environmentName}-001-apim'
var dnsName = 'vnet.internal'
var appGwName = '${environmentName}-appGw'
var deployApim = true
var deployAppEnv = true
var deployAppGw= true

resource logAnalyticsWorkspace'Microsoft.OperationalInsights/workspaces@2020-03-01-preview' existing = {
  name: logAnalyticsWorkspaceName
}

resource kv 'Microsoft.KeyVault/vaults@2019-09-01' existing = {
  name: keyVaultName
}

module vnetModule 'vnet.bicep' = {
  name: 'vnetDeploy'
  params: {
    virtualNetworkName: virtualNetworkName
    appgwSubnetName: appgwSubnetName
    apimSubnetName: apimSubnetName
    appsvcSubnetName: appsvcSubnetName
    location: location
  }
}

module appEnvModule 'appsvc-env.bicep' = {
  name: 'appEnvDeploy'
  params: {
    virtualNetworkName: virtualNetworkName
    subnetName: appsvcSubnetName
    environmentName: environmentName
    location: location
    logAnalyticsWorkspaceId: logAnalyticsWorkspace.id
    dnsName: dnsName
    wildcardCertificateBase64: kv.getSecret('vnet-internal-cert')
    deployAppEnv: deployAppEnv
  }
  dependsOn: [vnetModule]
}

module apimModule 'apim.bicep' = {
  name: 'apimDeploy'
  params: {
    location: location
    apimName: apimName
    virtualNetworkName: virtualNetworkName
    subnetName: apimSubnetName
    logAnalyticsWorkspaceId: logAnalyticsWorkspace.id
    dnsName: dnsName
    keyVaultName: keyVaultName
    identityName: miName
    rootCertificateBase64: kv.getSecret('root-cert')
    deployApim: deployApim
  }
  dependsOn: [vnetModule]
}

module dnsModule 'dns.bicep' = {
  name: 'dnsDeploy'
  params: {
    dnsName: dnsName
    virtualNetworkName: virtualNetworkName
    apimPrivateIp: apimModule.outputs.apimPrivateIp
  }
  dependsOn: [appEnvModule, apimModule]
}

module appGwModule 'appgw.bicep' = {
  name: 'appGwDeploy'
  params: {
    location: location
    environmentName: environmentName
    appGatewayName: appGwName
    virtualNetworkName: virtualNetworkName
    subnetName: appgwSubnetName
    logAnalyticsWorkspaceId: logAnalyticsWorkspace.id
    dnsName: dnsName
    identityName: miName
    keyVaultName: keyVaultName
    deployAppGw: deployAppGw
  }
  dependsOn: [apimModule]
}

output apimUrl string = 'https://${apimModule.outputs.apimHost}'
output appGwUrl string = appGwModule.outputs.appGwUrl

Deploy this template following the same steps used to deploy prerequisites.bicep file.

The third step is to deploy the application itself. It deploys the container application, register it with the API Management service and register it in the Private DNS. To prevent API Management Service from returning 301 with the URL to the Container App (which is not accessible), a policy is also deployed to the API Management Service to follow redirects.

The Following file is used in this template:

ping-app.bicep
param location string = resourceGroup().location
param environmentName string = 'test'
param dnsName string = 'vnet.internal'

var acrName = '${environmentName}0apps0acr'
var apimName = '${environmentName}-001-apim'
var apiName = 'ping-app'

resource environment 'Microsoft.App/managedEnvironments@2022-06-01-preview' existing = {
  name: '${environmentName}-env'
}

resource acr 'Microsoft.ContainerRegistry/registries@2021-09-01' existing = {
  name: acrName
}

resource apim 'Microsoft.ApiManagement/service@2022-04-01-preview' existing = {
  name: apimName
}

resource appEnvDnsZone 'Microsoft.Network/privateDnsZones@2020-06-01' existing= {
  name: dnsName
}

resource pingApp 'Microsoft.App/containerApps@2022-03-01' = {
  name: apiName
  location: location
  properties: {
    managedEnvironmentId: environment.id
    configuration: {
      ingress: {
        transport: 'auto'
        external: true
        targetPort: 80
        traffic: [
          {
            latestRevision: true
            weight: 100
          }
        ]
      }
      registries: [{
        server: acr.properties.loginServer
        username: acr.name
        passwordSecretRef: 'acr-password'
      }]
      secrets: [{
        name: 'acr-password'
        value: acr.listCredentials().passwords[0].value
      }]
    }
    template: {
      containers: [
        {
          image: '${acr.properties.loginServer}/${apiName}:latest'
          name: apiName
          resources: {
            cpu: '0.5'
            memory: '1.0Gi'
          }
        }
      ]
      scale: {
        minReplicas: 1
        maxReplicas: 1
      }
    }
  }
}

resource appARecord 'Microsoft.Network/privateDnsZones/A@2018-09-01' = {
  name: apiName
  parent: appEnvDnsZone
  properties: {
    ttl: 60
    aRecords: [
      {
        ipv4Address: environment.properties.staticIp
      }
    ]
  }
}

var hostName = environment.properties.customDomainConfiguration.dnsSuffix

resource apimReg 'Microsoft.ApiManagement/service/apis@2022-04-01-preview' = {
  parent: apim
  name: apiName
  properties: {
    displayName: apiName
    apiType: 'http'
    path: apiName
    protocols: ['https']
    format: 'openapi-link'
    serviceUrl: 'http://ping-app.${hostName}'
    value: 'http://ping-app.${hostName}/swagger/v1/swagger.json'
    subscriptionRequired: false
    isCurrent: true
  }
  dependsOn: [pingApp]
}

resource apimPolicy 'Microsoft.ApiManagement/service/apis/policies@2022-04-01-preview' = {
  parent: apimReg
  name: 'policy'
  properties: {
    value: '<policies>\r\n  <inbound>\r\n    <base />\r\n  </inbound>\r\n  <backend>\r\n    <forward-request timeout="10" follow-redirects="true" />\r\n  </backend>\r\n  <outbound>\r\n    <base />\r\n  </outbound>\r\n  <on-error>\r\n    <base />\r\n  </on-error>\r\n</policies>'
  }
  dependsOn: [pingApp]
}

Follow the same steps as the other two templates to deploy this template to the resource group.

Once the deployment is complete,

  • Obtain the public IP of the application gateway.
  • Open a browser and navigate to the following address http://<application gateway ip address>/ping-app/ping. This would result in HTTP 200 response with "true".

5. The Conclusion

In conclusion, in this blog, we discussed how to use Azure to build a secure and scalable solution using Azure Container Apps, API Management and Application Gateway. Since this application is running inside a virtual network and secured by a Network Security Group, it's highly secure. Further, this solution does not require a large amount of code since we are using bicep. By following the steps outlined in this project, you can easily deploy a similar infrastructure and customize it to meet your specific needs.

6. References: