00 — Architecture¶
What is being built, what it is defending against, and which alternatives were rejected on the way. Read this before running anything; every later chapter assumes the decisions made here.
The problem statement¶
A web application with a database, object storage and secrets. Ordinary. The question the platform has to answer is not "does it run" but:
- How does traffic arrive, and what inspects it before the application does?
- What can reach the database — and what can prove that nothing else can?
- Where do credentials live, and who ever sees them?
- When something is blocked, where does that show up?
- When someone changes a setting in the portal at 23:00, what stops them?
Each question maps to a chapter. Each answer is a resource in Bicep, and each is verified in chapter 10 rather than asserted.
Threat model¶
Not an exhaustive one — a lab-sized one, honest about what it does and does not cover.
| Threat | Control in this platform | Chapter |
|---|---|---|
| Automated scanning and injection against the public endpoint | Application Gateway WAF v2, OWASP CRS 3.2 in Prevention | 05 |
| Bypassing the WAF by hitting the app's own hostname | Access restriction pinned to the gateway subnet; private endpoint in the prod profile | 04 |
| Data exfiltration from storage using a leaked account key | allowSharedKeyAccess: false — there is no key |
03 |
| Database reachable from the internet | publicNetworkAccess: Disabled, private endpoint only |
03 |
| Secrets in source control, CI logs or app settings | Bootstrap vault + az.getSecret + Key Vault references + OIDC |
01, 09 |
| A "private" service that still resolves to a public IP | Private DNS zones linked to both VNets | 02 |
| Configuration drift and portal edits | Deployment stack with denyWriteAndDelete |
08 |
| A future deployment quietly re-opening public access | Azure Policy initiative with a Deny effect | 07 |
Out of scope, and worth saying so: DDoS protection (the Standard plan is USD ~3,000 a month), Azure Firewall for egress filtering, multi-region failover, customer-managed encryption keys, and Defender for Cloud. Each is a real control; none of them fit in a lab that has to cost about a euro.
The topology¶
flowchart TB
subgraph internet[" "]
USER(["client"])
end
subgraph rgnet["rg-ztwp-network-dev-weu"]
subgraph hubvnet["hub VNet 10.10.0.0/16"]
BAS["AzureBastionSubnet<br/>10.10.0.0/26"]
JUMP["snet-jumpbox<br/>10.10.1.0/24"]
end
subgraph spokevnet["spoke VNet 10.20.0.0/16"]
SAGW["snet-agw 10.20.0.0/24<br/>+ Microsoft.Web service endpoint"]
SAPP["snet-app 10.20.1.0/24<br/>delegated: Microsoft.Web/serverFarms"]
SPEP["snet-pep 10.20.2.0/24<br/>private endpoint NICs"]
end
DNS["privatelink zones ×4<br/>linked to both VNets"]
end
subgraph rgapp["rg-ztwp-app-dev-weu"]
AGW["Application Gateway WAF_v2"]
APP["App Service"]
end
subgraph rgplat["rg-ztwp-platform-dev-weu"]
KV["Key Vault"]
ST["Storage"]
PG["PostgreSQL Flexible"]
LAW["Log Analytics"]
end
USER -->|"80/443"| AGW
AGW -->|"lives in"| SAGW
AGW -->|"443, Host preserved"| APP
APP -->|"lives in"| SAPP
APP -->|"outbound, routeAll"| SPEP
SPEP --> KV
SPEP --> ST
SPEP --> PG
AGW --> LAW
APP --> LAW
Three resource groups, drawn along lifecycle lines rather than by resource type:
| Group | Holds | Why together |
|---|---|---|
rg-ztwp-network-* |
VNets, peerings, NSGs, private DNS, Bastion | changes rarely; owned by whoever owns addressing |
rg-ztwp-platform-* |
Key Vault, Storage, PostgreSQL, Log Analytics | stateful; deleting these loses data |
rg-ztwp-app-* |
App Service plan and site, WAF policy, gateway | redeployed most often; stateless |
If the application group is deleted and rebuilt, nothing of value is lost. That property is the reason for the split, and it is worth more than tidiness.
Decisions, and what they beat¶
Application Gateway, not Front Door¶
Front Door Standard is cheaper and global. Its Premium tier is what supports Private Link origins — at roughly USD 330 a month, and even then the origin is reachable through Microsoft's edge rather than through your own VNet. Application Gateway sits inside the spoke, which is what allows the backend to be resolved through the private DNS zone. The lab is about VNet-internal routing, so the gateway wins.
Private endpoint for PostgreSQL, not VNet injection¶
Flexible Server offers both, and they are mutually exclusive. VNet injection binds the server to one delegated subnet in one VNet for its entire life. A private endpoint is the same model as every other service here and can be extended to a second VNet later. The cost is one more endpoint, about USD 0.01 an hour.
An access restriction in dev, a private endpoint in prod¶
The most interesting trade-off in the platform, and the reason the two parameter files differ:
| dev profile | prod profile | |
|---|---|---|
publicNetworkAccess |
Enabled | Disabled |
| Inbound path | public endpoint, access restriction allows only snet-agw |
private endpoint in snet-pep |
| Failed request from elsewhere | 403 Ip Forbidden |
connection refused / NXDOMAIN |
| Kudu (SCM) console | reachable from the operator IP | not reachable from the internet at all |
The second column is stricter and takes the deployment and debugging surface off the internet with it. That is correct for production and hostile to a lab, where the Kudu console is the cheapest way to prove that private DNS resolution works from inside the VNet without paying for a Bastion host. Chapter 04 has you run both.
Kudu instead of a jumpbox¶
Verifying private DNS requires a shell inside the VNet. The obvious answer is a jumpbox and Azure Bastion — about USD 0.19 an hour for Bastion Basic, plus the VM. The Bastion Developer SKU is free but cannot traverse VNet peering, which makes it useless in a hub-and-spoke.
The App Service SSH console already runs inside the integrated subnet and ships with
nameresolver, tcpping and curl. It costs nothing and proves the same thing. Bastion
and the jumpbox stay in the repository as optional modules —
modules/network/bastion.bicep,
modules/network/jumpbox.bicep — because the prod
profile genuinely needs them.
Custom policy definitions, not built-ins¶
Built-in policies are referenced by GUID. A reviewer cannot tell what
/providers/Microsoft.Authorization/policyDefinitions/404c3081-a854-4457-ae30-26a93ef643f9
does without looking it up, and a template full of them is unreadable.
There is also a technical reason. The Key Vault in this platform deliberately keeps
publicNetworkAccess: Enabled and relies on networkAcls.defaultAction: Deny, because the
ARM resource provider must be able to write secrets into a vault it has just created. A
built-in policy demanding publicNetworkAccess: Disabled would reject the very platform it
was assigned to protect. The custom definition in
policy-rules/deny-public-network-access.json
encodes the distinction.
Hand-written modules, with one AVM chapter¶
Azure Verified Modules are the right default for production: Microsoft maintains them, they are tested, and they carry far more surface than anything written in an afternoon. They are the wrong default for a lab, because the interesting parts happen inside them.
Chapter 12 replaces one hand-written module with its AVM equivalent and diffs the result, so the trade-off is visible rather than argued.
What "zero trust" means here concretely¶
The phrase is worn out, so here is the operational version. Five properties, each provable with one command in chapter 10:
- One public IP. The gateway. Everything else answers only on RFC 1918 addresses.
- No shared secrets between components. The app authenticates to Key Vault and Storage as itself; the pipeline authenticates to Azure with a federated token.
- No account keys.
allowSharedKeyAccess: falseremoves the credential rather than protecting it. - Deny by default. NSGs, storage network ACLs, Key Vault ACLs and App Service access restrictions all default to deny and allow a named exception.
- Changes have exactly one route in. The deployment stack denies portal writes; the policy assignment denies non-compliant deployments.