Azure hub-spoke: private by default, changed only through review
A Terraform reference implementation of an Azure hub-spoke network: storage reachable only through a private endpoint, a WAF in Prevention mode in front of the workload, and a pipeline that applies nothing unless the plan matches the one a reviewer approved.
- Role
- Architecture, Terraform and delivery pipeline
- Period
- Oct 2026
- Status
- Public reference implementation
- Problem
- Data services reachable on public endpoints, spokes that can talk to each other by accident, and infrastructure changes reviewed as code diffs rather than as what will actually change.
- Key decision
- Private endpoints with centrally hosted DNS, a WAF in Prevention mode, isolation as the default, and a pipeline in which apply runs only after approval and only if the plan has not changed since.
- Result
- Storage has no public path, spokes cannot reach each other unless an explicit firewall rule allows it, pull requests only ever get read-only credentials, and formatting, validation, lint, a security scan and mocked plan tests run on every change.
Architecture
Components and flows as text
| Component | Kind | Technology | Flows out |
|---|---|---|---|
| Internet clients | client | HTTP(S) | → App Gateway WAF_v2: HTTP(S), inspected by the WAF |
| Log Analytics | datastore | 1 GB/day cap · resource tables | — |
| Azure Firewall | edge | optional · off by default | → Private endpoint: application rule: blob FQDN only |
| Private DNS zones | service | privatelink.* · linked to all VNets | — |
| App Gateway WAF_v2 | edge | Prevention · DRS 2.1 · bot rules | → App VM: backend pool; → Log Analytics: access and WAF logs |
| App VM | service | no public IP · no SSH rule | → Azure Firewall: default route via UDR (firewall on) |
| Private endpoint | edge | NSG-filtered endpoint subnet | → Storage account: private link |
| Storage account | datastore | public access off · no shared keys | — |
| GitHub Actions | pipeline | propose · gate · commit | ⇢ Entra ID: OIDC token exchange |
| Entra ID | external service | OIDC federated credentials | ⇢ Azure Resource Manager: short-lived token for plan or approved apply |
| Azure Resource Manager | service | Terraform azurerm 4.x | — |
Trust boundaries: Hub VNet (Log Analytics, Azure Firewall, Private DNS zones); Workload spoke (App Gateway WAF_v2, App VM); Data spoke (Private endpoint, Storage account).
Context and constraints
The design answers three questions a reviewer would ask of any Azure landing network: can anything reach the data from the Internet, can a workload reach something it should not, and can a change reach production without someone seeing exactly what it will do.
- Region and scope. One region, two spokes (workload and data), no on-premises connectivity.
- Cost-aware. The expensive components, Azure Firewall and the WAF gateway, are the only significant costs; the firewall is optional, budgets are set per resource group and log ingestion is capped.
- Nothing implicit. Compute subnets have default outbound access disabled, the private endpoint subnet enforces its NSG, and every route exists as a resource.
Verification
- On every pull request:
terraform fmt,terraform validate,tflintwith the azurerm ruleset, and atrivymisconfiguration scan with each accepted finding justified inline. - Mocked plan tests (
terraform test, no credentials needed) assert that the firewall is off by default, that only the app subnet routes through it when it is on, that the storage account has public access and shared keys disabled, that every resource group has a budget, and that invalid workload names are rejected. - docs/VERIFY.md lists the commands that prove each property in a live subscription: peering state, private DNS resolution from inside the spokes, rejected public access, WAF blocks and rate limiting in the firewall logs.
Decisions
Customer-managed hub-spoke over Virtual WAN
A hub VNet peered to each spoke. Every routing decision is a visible Terraform resource: peerings, route tables, firewall rules.
Options considered
- Virtual WAN with a secured hub: less plumbing, an hourly hub charge before any firewall, and less control over routing.
- Customer-managed hub: reviewable in a pull request and close to zero idle cost.
Trade-off accepted
Spoke-to-spoke transit has to be designed explicitly, and peerings and DNS links grow with the number of spokes.
Exit path
At tens of spokes, several regions or heavy branch connectivity, revisit Virtual WAN or Azure Virtual Network Manager.
The firewall is optional; isolation is the default
Azure Firewall sits behind a variable that defaults to off. Its subnets always exist, so the address plan never changes. Without it, peering is non-transitive and the spokes have no path to each other; with it, one application rule allows only the storage account's blob FQDN.
Options considered
- Always deploy the firewall: about three times the cost of everything else combined.
- Make it optional and keep the no-firewall state secure.
Trade-off accepted
With the firewall off, the workload tier cannot reach the data spoke at all, by design.
Private endpoints with centrally hosted DNS
The storage account has public network access and shared keys disabled and is reachable only through a private endpoint. privatelink zones live once in the hub and every VNet links to them; the endpoint registers its own record through a DNS zone group.
Options considered
- Service endpoints: keep the public endpoint and filter by subnet.
- Private endpoints: a private IP, provided every client resolves to it.
Trade-off accepted
Clients outside the linked VNets cannot reach the service at all, which is the point; DNS becomes part of the network design.
WAF in Prevention mode, with custom rules first
Application Gateway WAF_v2 with Microsoft Default Rule Set 2.1 and the bot manager rules, in Prevention mode. Two custom rules run first: admin paths blocked unless the client is allowlisted, and a per-client-IP rate limit. Authorization headers and cookies are scrubbed from WAF logs.
Options considered
- Start in Detection mode: nothing breaks, and attacks become unread log lines.
- Start in Prevention: false positives surface as blocked requests and get tuned.
Trade-off accepted
Legitimate requests can be blocked until exclusions are tuned; every WAF block, including rate limiting, returns 403.
OIDC with two identities, no secrets
GitHub Actions authenticates through workload identity federation. A plan identity (Reader) serves pull requests; an apply identity (Contributor) is only available to jobs inside the protected production environment.
Options considered
- One service principal with a client secret: simple, long-lived, and leaks through logs and forks.
- Federated credentials scoped by subject.
Trade-off accepted
More setup per environment; in exchange, a compromised branch can only ever obtain a read-only token.
Approval covers the exact change
Pull requests run fmt, validate, tflint, trivy and a speculative plan. On main, a fresh plan is published with a SHA-256 fingerprint of the planned changes; after a reviewer approves the production environment, the job re-plans under a lock and applies only if the fingerprint still matches.
Options considered
- Pass the plan file between jobs: plan files can hold sensitive values, and artifacts on a public repository are readable.
- Re-plan and compare fingerprints.
Trade-off accepted
Any drift between approval and apply fails the run and needs a new review.
Failure modes
| Failure | Detection | Handling | Evidence |
|---|---|---|---|
| Plan changes after approval | Fingerprint mismatch on re-plan | Apply refused; review again | apply.yml |
| Compromised pull request branch | OIDC subject is pull_request | Only the read-only identity is issued | ADR-0005 |
| Storage reached from the Internet | Public network access disabled | Rejected by the service | plan.tftest.hcl |
| Insecure configuration merged | tflint and trivy on every pull request | Pull request fails | propose.yml |
| Spoke-to-spoke access | Non-transitive peering | Only an explicit firewall rule allows it | ADR-0002 |
| Runaway cost | Budget per resource group | Alerts; daily log cap of 1 GB | envs/dev/cost.tf |
What I would change next
- Add Azure Policy assignments as guardrails alongside the Terraform checks.
- Move to the azurerm 5.x provider line.
- Revisit Virtual WAN or Azure Virtual Network Manager if the number of spokes grows.
Evidence index
Links point to the public repository.