04 — Compute: App Service with two network edges¶
Outbound through the VNet, inbound only from the gateway, and a configuration that holds a reference where a password used to be. Then the console that lets you prove all of it from inside the VNet for free.
Time: 40 minutes · Cost: ~USD 0.018/hour (B1 plan)
File: app-service.bicep
4.1 Two edges, not one¶
App Service has two independent network boundaries and confusing them is the most common mistake in this architecture:
| Direction | Feature | What it does | |
|---|---|---|---|
| Outbound | app → VNet | VNet integration | lets the app reach private endpoints |
| Inbound | client → app | access restrictions or private endpoint | decides who may reach the app |
Turning on VNet integration does nothing whatsoever for inbound traffic. An app with
integration and no inbound control is still reachable by anyone who knows its
*.azurewebsites.net name.
Outbound¶
vnetRouteAllEnabled is the second half. Without it only RFC 1918 destinations go through
the VNet, and calls to *.vault.azure.net — a public hostname that resolves privately —
take the internet path instead, missing the private endpoint entirely. This is the switch
that makes Key Vault references resolve over Private Link.
The subnet must be delegated to Microsoft.Web/serverFarms, which
chapter 02 already did.
Inbound, dev profile¶
publicNetworkAccess: 'Enabled'
ipSecurityRestrictionsDefaultAction: 'Deny'
ipSecurityRestrictions: [
{
vnetSubnetResourceId: applicationGatewaySubnetId
action: 'Allow'
priority: 100
name: 'AllowApplicationGatewaySubnet'
}
]
The rule names a subnet, not an IP address. That works because snet-agw carries the
Microsoft.Web service endpoint (added in chapter 02): traffic from the
gateway to the app leaves with a subnet identity, and the app can refuse everything else.
The alternative — allowing the gateway's public IP — works too, and breaks the day the
gateway's address changes.
Everything not matching gets 403 Ip Forbidden, which is exactly what chapter 10 checks for.
The SCM site has its own rules¶
scmIpSecurityRestrictionsUseMain: false
scmIpSecurityRestrictionsDefaultAction: empty(managementIpAddress) ? 'Allow' : 'Deny'
scmIpSecurityRestrictions: [ { ipAddress: managementIpAddress, action: 'Allow', priority: 100 } ]
The Kudu/SCM endpoint (<app>.scm.azurewebsites.net) handles deployments and the console.
Sharing the main rules would lock you out of it, because you are not the Application
Gateway. Your operator IP gets in; nothing else does.
Inbound, prod profile¶
param enableAppPrivateEndpoint bool = false
// ...
publicNetworkAccess: enablePrivateEndpoint ? 'Disabled' : 'Enabled'
Set it to true and the app leaves the public internet entirely, reachable only through a
private endpoint in snet-pep. Strictly better security — and it takes the SCM site with
it. Deployments and the console then require the jumpbox, a VPN, or a self-hosted CI runner
inside the VNet.
That is the correct production choice and a hostile lab default, which is why
main.dev.bicepparam leaves it false and
main.prod.bicepparam sets it true alongside
deployBastion: true. Run both and feel the difference.
4.2 Configuration that holds no secrets¶
var secretAppSettings = empty(databaseSecretUri) ? [] : [
{
name: 'DATABASE_URL'
value: '@Microsoft.KeyVault(SecretUri=${databaseSecretUri})'
}
]
@Microsoft.KeyVault(SecretUri=…) is a Key Vault reference. The platform resolves it at
startup using the app's managed identity; the app receives the value as an ordinary
environment variable and never learns where it came from.
For this to work, four things must all be true — and every one of them is a chapter:
- The app has a system-assigned identity —
identity: { type: 'SystemAssigned' }. - That identity holds Key Vault Secrets User on the vault — chapter 03, via
rbac.bicep. - The vault is reachable from the app — private endpoint plus private DNS, chapters 02–03.
- Outbound traffic actually goes through the VNet —
vnetRouteAllEnabled: true, above.
Break any one and the app starts with an empty setting and a message in the log that names none of the four. Troubleshooting covers the diagnosis.
Role assignments live with the resource, not the identity¶
module appRbac 'modules/shared/rbac.bicep' = {
scope: platformResourceGroup // where the vault and the account are
params: {
principalId: app.outputs.principalId
keyVaultName: keyVault.outputs.name
storageAccountName: storage.outputs.name
}
}
Two assignments, each scoped to a single resource:
resource keyVaultSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: keyVault
name: guid(keyVault.id, principalId, keyVaultSecretsUserRoleId)
properties: {
roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', keyVaultSecretsUserRoleId)
principalId: principalId
principalType: 'ServicePrincipal'
}
}
Two details worth stealing:
- The name is a deterministic GUID derived from scope + principal + role. Redeploying is a no-op instead of a conflict.
principalType: 'ServicePrincipal'avoids a race where Entra ID has not yet replicated a brand-new managed identity and the assignment fails withPrincipalNotFound.
4.3 The rest of the site configuration¶
httpsOnly: true
siteConfig: {
linuxFxVersion: 'NODE|20-lts'
alwaysOn: true
http20Enabled: true
minTlsVersion: '1.2'
scmMinTlsVersion: '1.2'
ftpsState: 'Disabled'
healthCheckPath: '/'
}
No application code is deployed. An empty Linux app serves App Service's default page with 200 OK, which is all the gateway health probe and the WAF tests need. The lab is about the platform; adding a repository to build would add a CI concern and change nothing about the network.
ftpsState: 'Disabled' closes a legacy publishing path that no one in this architecture
uses and everyone forgets exists.
4.4 The console that makes verification free¶
This is the trick that keeps the lab cheap. Open:
You are now in a shell inside the integrated subnet, and App Service ships three tools that answer the questions this architecture raises:
Expect 10.20.2.x. A public address means the DNS zone link is missing — go back to
chapter 02.
Expect a connection. From your laptop the same host is unreachable.
A response at all proves the vault answers over the private endpoint.
Doing the same with a jumpbox costs about USD 0.19/hour for Bastion Basic plus the VM.
Both modules are in the repository for the prod profile, which needs them —
bastion.bicep,
jumpbox.bicep.
The Bastion Developer SKU is free, and useless here: it cannot traverse VNet peering, so it cannot reach a spoke from a hub. That limitation is the reason the module defaults to Basic.
4.5 Verify¶
403. The app is up; you are simply not the Application Gateway.
az webapp config appsettings list --name <app> --resource-group rg-ztwp-app-dev-weu --query "[?name=='DATABASE_URL'].value" -o tsv
@Microsoft.KeyVault(SecretUri=https://…) — a reference, not a password.
az webapp config show --name <app> --resource-group rg-ztwp-app-dev-weu --query "{vnetRouteAll:vnetRouteAllEnabled, ftps:ftpsState, tls:minTlsVersion}" -o json
az webapp identity show --name <app> --resource-group rg-ztwp-app-dev-weu --query principalId -o tsv
Then confirm that principal really holds the two roles:
az role assignment list --assignee <principalId> --all --query "[].{role:roleDefinitionName, scope:scope}" -o table
Exercises¶
-
Remove the access restriction and redeploy.
curlthe app hostname directly: 200. You have just bypassed the WAF entirely — the same request with?id=1' OR '1'='1now reaches the application. Put it back. -
Flip to the private endpoint. Set
enableAppPrivateEndpoint = truein the dev parameter file and redeploy. The app hostname stops resolving publicly, and so does Kudu. Now you need the jumpbox — deploy it and reach the console through Bastion. -
Break the Key Vault reference by removing the
Key Vault Secrets Userassignment. Restart the app and readDATABASE_URL: empty. Then find the failure inAppServiceConsoleLogs— chapter 06 has the query.
Checkpoint¶
- The app answers 403 on its own hostname and 200 through the gateway
-
DATABASE_URLis a Key Vault reference -
nameresolverinside Kudu returns a10.20.2.xaddress for storage - The managed identity holds exactly two roles, each on one resource
Next: 05 — Edge and WAF