← all work

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
  • Terraform
  • azurerm 4.x
  • Virtual Network
  • Private Endpoints
  • Private DNS
  • Application Gateway WAF_v2
  • Azure Firewall
  • Log Analytics
  • GitHub Actions
  • OIDC
  • tflint
  • trivy
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

Azure hub-spoke architectureHUB VNETWORKLOAD SPOKEDATA SPOKECLIInternet clientsHTTP(S)DBLog Analytics1 GB/day cap · resource t…EDGEAzure Firewalloptional · off by defaultSVCPrivate DNS zonesprivatelink.* · linked to…EDGEApp Gateway WAF_v2Prevention · DRS 2.1 · bo…SVCApp VMno public IP · no SSH ruleEDGEPrivate endpointNSG-filtered endpoint sub…DBStorage accountpublic access off · no sh…CIGitHub Actionspropose · gate · commitEXTEntra IDOIDC federated credentialsSVCAzure Resource ManagerTerraform azurerm 4.xAzure hub-spoke architectureCLIInternet clientsHTTP(S)CIGitHub Actionspropose · gate · commitDBLog Analytics1 GB/day cap · resource tablesEDGEApp Gateway WAF_v2Prevention · DRS 2.1 · bot rulesEXTEntra IDOIDC federated credentialsEDGEAzure Firewalloptional · off by defaultSVCApp VMno public IP · no SSH ruleSVCAzure Resource ManagerTerraform azurerm 4.xSVCPrivate DNS zonesprivatelink.* · linked to all VNetsEDGEPrivate endpointNSG-filtered endpoint subnetDBStorage accountpublic access off · no shared keys
A hub holds shared DNS, logging and the optional firewall. The workload spoke takes Internet traffic only through the WAF; the data spoke exposes storage only through a private endpoint. Changes arrive only through the pipeline. Hover, tap or tab through the components; control flows are dashed.
Components and flows as text
ComponentKindTechnologyFlows out
Internet clientsclientHTTP(S)→ App Gateway WAF_v2: HTTP(S), inspected by the WAF
Log Analyticsdatastore1 GB/day cap · resource tables—
Azure Firewalledgeoptional · off by default→ Private endpoint: application rule: blob FQDN only
Private DNS zonesserviceprivatelink.* · linked to all VNets—
App Gateway WAF_v2edgePrevention · DRS 2.1 · bot rules→ App VM: backend pool; → Log Analytics: access and WAF logs
App VMserviceno public IP · no SSH rule→ Azure Firewall: default route via UDR (firewall on)
Private endpointedgeNSG-filtered endpoint subnet→ Storage account: private link
Storage accountdatastorepublic access off · no shared keys—
GitHub Actionspipelinepropose · gate · commit⇢ Entra ID: OIDC token exchange
Entra IDexternal serviceOIDC federated credentials⇢ Azure Resource Manager: short-lived token for plan or approved apply
Azure Resource ManagerserviceTerraform 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, tflint with the azurerm ruleset, and a trivy misconfiguration 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

  1. D-01accepted

    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.

    ADR-0001 ↗ · modules/hub ↗

  2. D-02accepted

    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.

    ADR-0002 ↗ · modules/hub/firewall.tf ↗ · envs/dev/tests/plan.tftest.hcl ↗

  3. D-03accepted

    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.

    ADR-0003 ↗ · modules/private-endpoint ↗ · modules/hub/dns.tf ↗

  4. D-04accepted

    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.

    ADR-0004 ↗ · modules/app-gateway-waf/waf-policy.tf ↗

  5. D-05accepted

    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.

    ADR-0005 ↗ · docs/deployment-identity.md ↗

  6. D-06accepted

    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.

    ADR-0006 ↗ · .github/workflows/apply.yml ↗ · .github/workflows/propose.yml ↗

Failure modes

FailureDetectionHandlingEvidence
Plan changes after approvalFingerprint mismatch on re-planApply refused; review againapply.yml
Compromised pull request branchOIDC subject is pull_requestOnly the read-only identity is issuedADR-0005
Storage reached from the InternetPublic network access disabledRejected by the serviceplan.tftest.hcl
Insecure configuration mergedtflint and trivy on every pull requestPull request failspropose.yml
Spoke-to-spoke accessNon-transitive peeringOnly an explicit firewall rule allows itADR-0002
Runaway costBudget per resource groupAlerts; daily log cap of 1 GBenvs/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.