Project Case Study
Fintech Compliance Platform on Microsoft Azure
Project summary
A hub and spoke Azure landing zone for a fintech workload, built entirely in Terraform, with every control mapped to a specific New Zealand regulatory requirement, and every design decision written down, including the ones I got wrong on the first pass.
Why this architecture matters
When a regulator or an internal risk team asks how encryption is ensured, everywhere, all the time, the honest answer needs to be a policy the platform enforces, not a wiki page someone updates when they remember to. Infrastructure as code turns compliance from a manual checklist into controls that can be reviewed in a pull request, applied consistently, and continuously checked by Azure Policy's compliance dashboard.
This build follows the shape of Microsoft's Financial Services Industry (FSI) landing zone guidance, an Azure landing zone variant offering opinionated, infrastructure-as-code patterns for financial services customers with complex, highly regulated workloads, scaled to a single-subscription build that still demonstrates the same governance-first thinking end to end: a hub for centralised network control, a segmented spoke for the workload itself, and defence-in-depth layered on top rather than assumed.
Regulatory framework this design addresses
Framework What it covers How the architecture addresses it Privacy Act 2020 (NZ) Collection, use, disclosure, storage, and cross-border transfer of personal information, governed by 13 information privacy principles Encryption at rest (TDE) and in transit (TLS 1.2+), zero public endpoints on any PaaS data service, RBAC-scoped access, single-region data residency RBNZ BS11, Outsourcing Policy Governance, risk management, and monitoring of outsourced or cloud-hosted functions for registered banks Full audit trail in Git and Terraform state, NSG flow logs with Traffic Analytics on every subnet, immutable audit log archive AML/CFT Act 2009 (NZ) Anti-money laundering and countering financing of terrorism obligations Session-ordered Service Bus queue for transaction processing, SQL extended auditing, Microsoft Sentinel detections ISO/IEC 27001:2022 Information security management system controls Custom least-privilege RBAC roles, centralised logging, Key Vault-managed secrets PCI DSS Payment card data security Hub and spoke network segmentation, Azure Firewall default-deny egress, WAF_v2 on the public edge, encryption throughout
This project is a portfolio build, not a certified compliance product. It demonstrates the architectural patterns these frameworks expect, a real deployment would still need a proper risk assessment, legal sign-off, and independent audit.
Architecture overview

At the top, Azure Policy is assigned once, as a single initiative grouping six custom controls, and cascades down to every subscription underneath when management group governance is enabled. Inside the subscription, a segmented virtual network runs a public subnet fronted by an Application Gateway on the WAF_v2 tier, an application subnet holding a private AKS cluster and a Service Bus namespace for ordered transaction processing, and a data subnet holding an Azure SQL Database with Transparent Data Encryption. Azure Key Vault holds every secret and certificate, and Azure Monitor collects logs and metrics from every tier into one place.
Hub and spoke network topology

A single, well segmented VNet still has one property worth being honest about: every subnet in it can reach the internet directly on Azure's own default system route unless something explicit removes that option. The hub VNet exists specifically to remove it. Azure Firewall sits on a default-deny policy with an explicit allow list, the application and data subnets route all outbound traffic through it via a user-defined route, and Azure Bastion means no virtual machine anywhere in the platform needs a public IP for administrative access. The two VNets are peered in both directions, and the peering itself lives at the composition level rather than inside either module, so the hub and the spoke stay independently reusable without one needing to know the other exists.
Network segmentation within the spoke

PCI DSS and plain good practice both call for real segmentation between tiers that handle financial data and everything else. Each subnet's Network Security Group only accepts traffic from the subnet directly upstream of it, the data subnet's NSG has never heard of the internet at all, and NSG flow logs with Traffic Analytics on every subnet turn "we segmented the network" into "we can show exactly what crossed a boundary, and when."
Management group hierarchy

Azure Policy assignments cascade down this hierarchy when management group governance is enabled. A policy attached at the Workload management group is inherited by every subscription created underneath it, Dev, Staging, or Prod, without anyone reapplying it. This is gated behind a variable in the Terraform, since creating management groups needs Owner-equivalent rights at the tenant root, a permission level a personal or trial subscription genuinely does not have. The default deployment path assigns the same controls at resource group scope instead, which works with ordinary Contributor access.
Component breakdown
Hub network: Azure Firewall and Bastion
Centralised egress filtering on a default-deny policy with an explicit allow list (Microsoft Entra ID endpoints, Azure Monitor ingestion, AKS and container registry FQDNs), plus browser-based administrative access with no VM ever needing a public IP.
Spoke network: three tiers, NSGs, route tables, flow logs
Public, application, and data subnets, each with its own NSG on a default-deny posture, route tables forcing the application and data subnets' egress through the firewall, and NSG flow logs with Traffic Analytics feeding the shared Log Analytics workspace.
Key Vault, RBAC authorisation, generated credentials
RBAC authorisation instead of the legacy access policy model, so vault permissions are reviewed the same way as every other role assignment in the platform. The SQL administrator password is generated by Terraform and written straight to the vault, it never appears in a .tfvars file or in Git history. A self-signed placeholder TLS certificate is generated the same way for the Application Gateway, clearly marked as something to replace with a real certificate authority before production traffic touches it.
Azure SQL Database with TDE
Transparent Data Encryption as its own explicit resource, TLS 1.2 enforced as a minimum, no public network access, a private endpoint into the data subnet, and extended auditing feeding the shared Log Analytics workspace.
Private AKS cluster
Private API endpoint, Microsoft Entra ID authentication with Azure RBAC deciding cluster permissions rather than a separate Kubernetes RBAC model, workload identity federation letting pods read secrets from Key Vault without a stored credential ever landing in a Kubernetes Secret.
Application Gateway, WAF_v2
The sole public entry point into the platform. OWASP 3.2 rule set in Prevention mode, TLS 1.2 minimum, certificate sourced from Key Vault rather than uploaded as a file.
Service Bus Premium, ordered transaction processing
A session-enabled queue guaranteeing strict, in-order delivery per account or customer, chosen over Event Grid specifically because Event Grid's fan-out, at-least-once model does not guarantee ordering, and two transactions on the same account processed out of order is a correctness bug in a ledger, not a nuisance. Premium tier specifically for private endpoint support, no public listener on the namespace.
Private Azure Container Registry
Premium tier, admin credential disabled entirely, AKS pulling images through its kubelet's managed identity rather than any stored credential.
Immutable, WORM audit log archive
A dedicated storage account with blob versioning, change feed, and a time-based immutability policy locked so that once set, the retention period cannot be shortened and the container cannot be deleted before every blob inside it reaches the end of its own retention clock, not even by a Global Administrator. Separate from Log Analytics retention specifically because Log Analytics data can be deleted by anyone with sufficient RBAC on the workspace, and "provably unaltered" is a materially stronger claim than "retained for ninety days."
Recovery Services vault and backup enforcement
A real vault with a daily backup policy, which let the platform's backup enforcement policy assignment go from a comment explaining why it couldn't be active yet, to a working DeployIfNotExists assignment with the identity permissions it actually needs to remediate non-compliant resources.
Microsoft Defender for Cloud
Subscription-wide posture management across SQL, Storage, Key Vault, AKS, and Container Registry, because a resource being configured correctly at deployment time and a resource being continuously checked as correctly configured are different claims, and only one of them is what "secure by default" actually means.
Microsoft Sentinel
Onboarded onto the same Log Analytics workspace every other module already sends diagnostics to, with real scheduled detection rules (repeated SQL authentication failures, a spike in Azure Policy non-compliance events) rather than an empty onboarding.
Custom RBAC roles
Two roles matching real job functions on this platform rather than reaching for a built-in role that grants more than the job needs: a Platform Auditor that can verify controls and read policy compliance state but is explicitly denied access to Key Vault secret values, and a Workload Operator that can run day-to-day AKS operations but is explicitly denied access to network security groups, Key Vault writes, or resource deletion.
Azure Policy: one initiative, not six assignments
Six custom policy definitions, mandatory tags, storage encryption, SQL TLS enforcement, no public SQL access, restricted VM SKUs, backup tagging, grouped into a single azurerm_policy_set_definition initiative and assigned as one unit, moving from Audit mode in development to Deny mode in staging and production through a single variable.
Cost governance
A budget alert on the resource group at 80% of actual spend and a forecast that would exceed 100%, because a platform this thorough, hub firewall, Bastion, Premium Service Bus, Premium Container Registry, WAF_v2 Application Gateway, is genuinely expensive to leave running by accident.
How I built this
The article this project supports argues that Terraform functions as a compliance record, not just a provisioning tool, and that regulators assess what is actually deployed, not a roadmap. I wanted this repository held to the same standard as its own argument, so here is the actual sequence it was built in, not a tidied-up version of it.
Build steps, in the order I actually did them
Set up the Terraform project skeleton first, before any real infrastructure:
providers.tf(azurerm, azuread, random providers),backend.tffor remote state, and a rootvariables.tfwith the handful of inputs every module would need (environment name, location, project name, tags). Nothing here creates a resource, it just gives every later step somewhere consistent to plug into.Built the resource group and the spoke network, three subnets by trust tier (public, application, data), one NSG per subnet on a default-deny posture. This is the foundation everything else attaches to, so it came before anything that needed a subnet to live in.
Added Key Vault and generated credentials before any resource that would need a secret. The SQL administrator password and the Application Gateway's placeholder certificate are both generated by Terraform and written straight to the vault, so by the time the database or the gateway modules existed, they had somewhere real to pull credentials from rather than a variable holding a password in plain text.
Built the data tier: Azure SQL with Transparent Data Encryption as its own explicit resource, TLS 1.2 enforced, no public network access, a private endpoint into the data subnet.
Built the application tier: a private AKS cluster, Microsoft Entra ID authentication with Azure RBAC, workload identity federation so pods could reach Key Vault without a stored credential.
Wrote the first six custom Azure Policy definitions and assigned them individually at resource group scope, tags, storage encryption, SQL TLS, no public SQL access, restricted VM SKUs, backup tagging (the last one commented out at this point, no vault existed yet).
Checked the result against the article's own reference architecture list, and found two gaps: no WAF, no message queue. Built the Application Gateway (WAF_v2) and Service Bus (Premium, session-enabled queue) modules specifically to close them, choosing Service Bus over Event Grid because of the ordering guarantee a financial ledger actually needs.
Rebuilt the network as a real hub and spoke topology, after review feedback that a segmented single VNet still isn't a landing zone. Added the hub VNet, Azure Firewall on a default-deny policy, Azure Bastion, route tables forcing the spoke's application and data subnets through the firewall, and peering in both directions.
Added NSG flow logs with Traffic Analytics on every subnet, so the new firewall and route tables had visibility to go with them, a control nobody can verify without logs showing it actually happened.
Went through the article's reference architecture and its case study a second time, and added everything still missing: Microsoft Defender for Cloud, Microsoft Sentinel with real detection rules, a private Container Registry, a Recovery Services vault (which let the commented-out backup policy from step 6 finally go live), immutable WORM audit log storage, two custom least-privilege RBAC roles, and a budget alert.
Consolidated the six individual policy assignments into one initiative, matching the article's own description of the mature pattern, policies built into initiatives, applied through infrastructure as code, rather than managed as six separate moving parts.
Validated continuously, not just at the end. Every
.tffile was parsed withpython-hcl2after being written, not batched up, and a cross-reference script checked every module's inputs against its declared variables to catch stale wiring left over from refactors.Built the diagrams last, from the finished Terraform, not before it. Each SVG was rendered to PNG and actually inspected before being called finished, which is how two real mistakes got caught, an arrow crossing through a text label, and a database icon with a malformed arc path, neither of which was visible just reading the markup.
Wrote the architecture decision record and this build process last of all, once the platform itself was stable enough that the reasoning being documented wouldn't need rewriting the next day.
The five phases, in more detail
Phase 1 started smaller than the final architecture. A single VNet, three subnets by trust tier, Azure SQL with TDE, a private AKS cluster, Key Vault, and six policy definitions assigned individually. This covered the article's encryption and segmentation claims reasonably well, but I knew going in that it did not yet cover everything the article's own reference architecture listed, a WAF, a message queue, a SIEM, a container registry, and a backup policy that existed in code but was commented out because no Recovery Services vault existed for it to target. I left that gap visible rather than hiding it.
Phase 2 closed the gap between the diagram and the code. An early diagram showed an Application Gateway labelled "WAF, TLS termination" with no Terraform resource behind it, a picture is not a control. I went through the article's reference architecture list item by item against what actually existed, and built the Application Gateway and Service Bus modules that were missing. Choosing Service Bus over Event Grid for the transaction queue was a real decision, not just an implementation task, based specifically on Service Bus sessions guaranteeing the strict ordering a financial ledger needs and Event Grid's delivery model not providing.
Phase 3 rebuilt the network as an actual hub and spoke topology, after getting direct feedback that a segmented single VNet still isn't a landing zone. This was the largest single pass: Azure Firewall, Azure Bastion, route tables forcing egress through the firewall, and peering between the two VNets. The real technical snag here was a suspected circular dependency, the hub needing the spoke's VNet ID for peering, and the spoke needing the hub's firewall IP for its route table. On inspection it wasn't a hard cycle, Terraform resolves dependencies at the resource level, not the module level, so two modules can reference each other's outputs as long as the specific resources involved don't loop back into themselves. But I moved the peering out of both modules and into the root configuration anyway, specifically so the non-cycle was visibly obvious to a future reader, not just technically true.
Phase 4 went back through the article's reference architecture and its case study a second time, adding Microsoft Defender for Cloud, Microsoft Sentinel with real detection rules rather than an empty onboarding, a private container registry (a gap I only noticed once I traced through where AKS would actually pull images from and found no real answer), a Recovery Services vault that finally let the commented-out backup policy go live, immutable WORM audit log storage, two custom least-privilege RBAC roles, and a budget alert once the platform's always-on cost surface got real enough to need one.
Phase 5 brought the documentation up to the same standard as the code. The reasoning behind every decision now lives in a dedicated architecture decision record, one entry per major choice, each with its context, its implementation, its rationale, the alternatives that were turned down, and the honest trade-offs it costs, not just the upside. Building the hub-and-spoke diagram by hand and actually rendering it to check the layout caught two real mistakes an eyeballed read of the markup wouldn't have: an arrow drawn straight through a text label from a miscalculated coordinate, and a database icon rendering with stray lines running off the canvas because of transposed values in an SVG arc path. Both were the same lesson as validating Terraform by parsing it instead of assuming it's correct because it reads fine.
Deployment instructions
Prerequisites
An Azure subscription with Contributor access (this build was developed and validated for syntax without a live subscription attached, using
python-hcl2to parse every.tffile and a cross-reference script checking every module's inputs against its declared variables)Terraform 1.5 or later
kubectl
Git
Deploy
az login
az account set --subscription "YOUR_SUBSCRIPTION_ID"
git clone https://github.com/yourusername/fintech-terraform.git
cd fintech-terraform
terraform init -backend-config=environments/dev/backend.hcl
terraform plan -var-file=environments/dev/terraform.tfvars
terraform apply -var-file=environments/dev/terraform.tfvars
Verify
az aks get-credentials --resource-group $(terraform output -raw resource_group_name) \
--name $(terraform output -raw aks_cluster_name)
kubectl get nodes
az policy state list --resource-group $(terraform output -raw resource_group_name)
Tear down
terraform destroy -var-file=environments/dev/terraform.tfvars
This platform includes several always-on billed resources, Azure Firewall, Bastion, WAF_v2 Application Gateway, Premium Service Bus, Premium Container Registry, destroy between working sessions rather than leaving it running idle.
Cost management
Action Impact Smallest viable SKUs per environment (set in environments/*/terraform.tfvars) Keeps monthly spend proportionate to what's actually needed terraform destroy when not actively working on it The single biggest lever, given how many always-on components this platform now has Budget alert at 80% actual and 100% forecast spend Flags a problem before it becomes a surprise, rather than relying on remembering to check Azure Cost Management, checked against the budget regularly Keeps actual spend visible against the threshold
What I'd extend next
CI pipeline (GitHub Actions) running
terraform planon pull request andterraform applyon merge, withterraform validateandtflintas required checksAutomated policy-as-code testing with Conftest or Open Policy Agent against the Terraform plan output, ahead of
applyMigrating hand-rolled modules to genuine Azure Verified Modules where a maintained equivalent exists
Azure Firewall Premium for TLS inspection and IDPS, a real upgrade path once the platform is carrying production traffic rather than a portfolio workload
Workload identity federation for CI, replacing interactive
az loginentirely