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 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.