A complete 17-phase implementation guide for a production-grade three-tier web portal on Azure: VNet isolation, private SQL, WAF at the edge, Managed Identity throughout, Azure DevOps CI/CD with DEV → UAT → PROD gates, blue/green slot swap, and Playwright cross-browser E2E testing as a deploy gate.
Full architecture: Front Door Premium → App Gateway WAF_v2 → VNet with App Services (React + .NET 8) → Azure SQL private endpoint. Shared services + CI/CD pipeline below.click to zoom
Three-tier layout inside a single VNet (10.0.0.0/16). All PaaS data-plane traffic stays on private endpoints — no SQL, Key Vault, or Service Bus traffic ever reaches the public internet once it enters Azure.
Azure resources needed before first terraform apply:
Requirement
Why
Azure subscription with Owner role
Required to assign RBAC roles
Azure CLI 2.60+
az bicep, az deployment, az webapp
Terraform 1.8+ or OpenTofu 1.9+
azurerm ~> 4.0 provider
Azure DevOps organisation
Pipeline hosting
Node 20 LTS
Frontend build + Playwright
.NET 8 SDK
API build
Register required providers once per subscription:
az provider register --namespace Microsoft.Network
az provider register --namespace Microsoft.Web
az provider register --namespace Microsoft.Sql
az provider register --namespace Microsoft.KeyVault
az provider register --namespace Microsoft.Cache
az provider register --namespace Microsoft.ServiceBus
az provider register --namespace Microsoft.AppConfiguration
az provider register --namespace Microsoft.Insights
az provider register --namespace Microsoft.OperationalInsights
az provider register --namespace Microsoft.Cdn # Front Door
Cost Estimate (DEV environment, East US)
Resource
SKU
Monthly (approx.)
App Gateway WAF_v2
Fixed capacity
~$130
Front Door Premium
Per-request
~$35
App Service Plan (×2)
P1v3 Linux
~$140
Azure SQL
Standard S1 (20 DTU)
~$30
Redis Cache
Standard C1
~$55
Service Bus
Standard
~$10
Key Vault
Standard
~$5
Log Analytics
Pay-per-GB
~$15
Total DEV
~$420/month
PROD with zone-redundant SQL and autoscale adds roughly 2× this. Use Azure Cost Management alerts at 80% of budget.
Phase 1 — Remote State Backend
Create the storage account before any Terraform runs. This is a one-time manual step because Terraform can't manage the thing that stores its own state.
terraform{required_version=">= 1.8"required_providers{azurerm={source="hashicorp/azurerm"version="~> 4.0"}random={source="hashicorp/random"version="~> 3.6"}}backend "azurerm" {resource_group_name="rg-tfstate"storage_account_name="saportalstate"# replace with your SA namecontainer_name="tfstate"key="portal.tfstate"}}provider "azurerm" {features{key_vault{purge_soft_delete_on_destroy=falserecover_soft_deleted_key_vaults=true}resource_group{prevent_deletion_if_contains_resources=true}}}
variable "env" {type= string }variable "location" {type= string }variable "rg_name" {type= string }variable "tenant_id" {type= string }variable "devops_sp_oid" {type= string }# pipeline service principal OIDdata "azurerm_client_config""current"{}resource "azurerm_key_vault""main"{name="kv-portal-${var.env}"resource_group_name= var.rg_name
location= var.location
tenant_id= var.tenant_id
sku_name="standard"enable_rbac_authorization=true# RBAC mode — no access policiespurge_protection_enabled=truesoft_delete_retention_days=90public_network_access_enabled=false# Private endpoint only}# Grant the DevOps pipeline SP read access to secretsresource "azurerm_role_assignment""devops_kv_reader"{scope= azurerm_key_vault.main.id
role_definition_name="Key Vault Secrets User"principal_id= var.devops_sp_oid
}# Grant the current Terraform principal admin access for seeding secretsresource "azurerm_role_assignment""tf_kv_admin"{scope= azurerm_key_vault.main.id
role_definition_name="Key Vault Secrets Officer"principal_id= data.azurerm_client_config.current.object_id
}# Private endpoint for Key Vault (into shared-subnet)resource "azurerm_private_endpoint""kv"{name="pe-kv-${var.env}"resource_group_name= var.rg_name
location= var.location
subnet_id= var.shared_subnet_id
private_service_connection{name="kv-psc"private_connection_resource_id= azurerm_key_vault.main.id
subresource_names=["vault"]is_manual_connection=false}private_dns_zone_group{name="kv-dns-group"private_dns_zone_ids=[azurerm_private_dns_zone.kv.id]}}resource "azurerm_private_dns_zone""kv"{name="privatelink.vaultcore.azure.net"resource_group_name= var.rg_name
}resource "azurerm_private_dns_zone_virtual_network_link""kv"{name="kv-dns-link"resource_group_name= var.rg_name
private_dns_zone_name= azurerm_private_dns_zone.kv.name
virtual_network_id= var.vnet_id
registration_enabled=false}output "kv_id" {value= azurerm_key_vault.main.id }output "kv_uri" {value= azurerm_key_vault.main.vault_uri }
Phase 4 — Azure SQL & Private DNS
variable "sql_admin_login" {type= string }variable "sql_admin_password" {type= string
sensitive=true}resource "azurerm_mssql_server""main"{name="sql-portal-${var.env}"resource_group_name= var.rg_name
location= var.location
version="12.0"administrator_login= var.sql_admin_login
administrator_login_password= var.sql_admin_password
public_network_access_enabled=false# CRITICAL — no public accessazuread_administrator{login_username="portal-dba-group"object_id= var.dba_group_oid
azuread_authentication_only=false}}resource "azurerm_mssql_database""main"{name="portaldb"server_id= azurerm_mssql_server.main.id
collation="SQL_Latin1_General_CP1_CI_AS"max_size_gb=32sku_name="S1"zone_redundant= var.env =="prod" ? true : falseshort_term_retention_policy{retention_days=7backup_interval_in_hours=12}}# Private endpoint for SQLresource "azurerm_private_endpoint""sql"{name="pe-sql-${var.env}"resource_group_name= var.rg_name
location= var.location
subnet_id= var.data_subnet_id
private_service_connection{name="sql-psc"private_connection_resource_id= azurerm_mssql_server.main.id
subresource_names=["sqlServer"]is_manual_connection=false}private_dns_zone_group{name="sql-dns-group"private_dns_zone_ids=[azurerm_private_dns_zone.sql.id]}}# Private DNS zone — REQUIRED for the FQDN to resolve to 10.0.2.x inside the VNetresource "azurerm_private_dns_zone""sql"{name="privatelink.database.windows.net"resource_group_name= var.rg_name
}resource "azurerm_private_dns_zone_virtual_network_link""sql"{name="sql-dns-link"resource_group_name= var.rg_name
private_dns_zone_name= azurerm_private_dns_zone.sql.name
virtual_network_id= var.vnet_id
registration_enabled=false}
Why the Private DNS zone matters: Even after you create a private endpoint, the SQL FQDN (sql-portal-dev.database.windows.net) still resolves to the public IP by default. The Private DNS zone overrides this so the name resolves to 10.0.2.x from within the VNet. Without it, a connection from App Service would fail because the DNS lookup returns the public IP, which is blocked.
# One Service Plan per app (independent autoscale)resource "azurerm_service_plan""api"{name="asp-portal-api-${var.env}"resource_group_name= var.rg_name
location= var.location
os_type="Linux"sku_name="P1v3"}resource "azurerm_linux_web_app""api"{name="app-portal-api-${var.env}"resource_group_name= var.rg_name
location= var.location
service_plan_id= azurerm_service_plan.api.id
https_only=truesite_config{minimum_tls_version="1.2"ftps_state="Disabled"health_check_path="/health"health_check_eviction_time_in_min=5application_stack{dotnet_version="8.0"}ip_restriction_default_action="Deny"ip_restriction{service_tag="AppGateway"priority=100action="Allow"description="Allow traffic from App Gateway only"}}identity{type="SystemAssigned"}app_settings={"APPLICATIONINSIGHTS_CONNECTION_STRING"="@Microsoft.KeyVault(VaultName=${var.kv_name};SecretName=ai-connection-string)""AZURE_SQL_CONNECTION_STRING"="@Microsoft.KeyVault(VaultName=${var.kv_name};SecretName=sql-connection-string)""SERVICEBUS_CONNECTION_STRING"="@Microsoft.KeyVault(VaultName=${var.kv_name};SecretName=sb-connection-string)""REDIS_CONNECTION_STRING"="@Microsoft.KeyVault(VaultName=${var.kv_name};SecretName=redis-connection-string)""ASPNETCORE_ENVIRONMENT"= var.aspnet_env
}}resource "azurerm_service_plan""web"{name="asp-portal-web-${var.env}"resource_group_name= var.rg_name
location= var.location
os_type="Linux"sku_name="P1v3"}resource "azurerm_linux_web_app""web"{name="app-portal-web-${var.env}"resource_group_name= var.rg_name
location= var.location
service_plan_id= azurerm_service_plan.web.id
https_only=truesite_config{minimum_tls_version="1.2"ftps_state="Disabled"health_check_path="/"application_stack{node_version="20-lts"}ip_restriction_default_action="Deny"ip_restriction{service_tag="AppGateway"priority=100action="Allow"}}app_settings={"REACT_APP_API_URL"="https://app-portal-api-${var.env}.azurewebsites.net"}}
Phase 7 — VNet Integration & Managed Identity
# VNet integration — routes outbound traffic through app-subnetresource "azurerm_app_service_virtual_network_swift_connection""api"{app_service_id= azurerm_linux_web_app.api.id
subnet_id= var.app_subnet_id
}resource "azurerm_app_service_virtual_network_swift_connection""web"{app_service_id= azurerm_linux_web_app.web.id
subnet_id= var.app_subnet_id
}# Grant API managed identity access to Key Vault secretsresource "azurerm_role_assignment""api_kv"{scope= var.kv_id
role_definition_name="Key Vault Secrets User"principal_id= azurerm_linux_web_app.api.identity[0].principal_id
}# Grant API managed identity access to SQL (create contained user in SQL separately)resource "azurerm_role_assignment""api_storage"{scope= var.storage_id
role_definition_name="Storage Blob Data Contributor"principal_id= azurerm_linux_web_app.api.identity[0].principal_id
}# Grant API managed identity access to Service Busresource "azurerm_role_assignment""api_sb"{scope= var.servicebus_id
role_definition_name="Azure Service Bus Data Sender"principal_id= azurerm_linux_web_app.api.identity[0].principal_id
}
SQL connection string using Managed Identity (no password):
// appsettings.json — pulled from Key Vault at startup{"ConnectionStrings":{"PortalDb": "Server=tcp:sql-portal-prod.database.windows.net,1433; Authentication=Active Directory Default; Database=portaldb; Encrypt=True;TrustServerCertificate=False;"
}}
Create a contained database user in SQL for the Managed Identity (one-time, run as Azure AD admin):
CREATEUSER[app-portal-api-prod]FROM EXTERNAL PROVIDER;ALTER ROLE db_datareader ADD MEMBER [app-portal-api-prod];ALTER ROLE db_datawriter ADD MEMBER [app-portal-api-prod];
Create a Variable Group portal-secrets-$(env) in Azure DevOps Pipelines → Library for each environment with: ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_SUBSCRIPTION_ID, ARM_TENANT_ID, SQL_ADMIN_PASSWORD.
import{ test, expect }from'@playwright/test'test.describe('Portal smoke tests',()=>{test('home page loads over HTTPS',async({ page, request })=>{const response =await request.get('/')expect(response.status()).toBe(200)await page.goto('/')awaitexpect(page).toHaveTitle(/Portal/)awaitexpect(page.locator('h1')).toBeVisible()})test('API health endpoint returns 200',async({ request })=>{const res =await request.get('/api/health')expect(res.status()).toBe(200)const json =await res.json()expect(json.status).toBe('healthy')})test('security headers present',async({ request })=>{const res =await request.get('/')expect(res.headers()['strict-transport-security']).toBeTruthy()expect(res.headers()['x-content-type-options']).toBe('nosniff')expect(res.headers()['x-frame-options']).toBeTruthy()})test('sign-in redirects to Entra ID',async({ page })=>{await page.goto('/login')await page.click('[data-testid="sign-in-button"]')awaitexpect(page).toHaveURL(/login\.microsoftonline\.com/)})test('navigation links are reachable',async({ page })=>{await page.goto('/')const links = page.locator('nav a')const count =await links.count()expect(count).toBeGreaterThan(0)})})
Run against any environment:
# Local devBASE_URL=http://localhost:3000 npx playwright test# Against DEVBASE_URL=https://app-portal-web-dev.azurewebsites.net npx playwright test# Specific browser onlyBASE_URL=https://app-portal-web-dev.azurewebsites.net \ npx playwright test--project=chromium
# Show HTML report after a runnpx playwright show-report playwright-report
Phase 15 — First Deployment Walkthrough
Complete sequence for bootstrapping a new environment (e.g., DEV):
# 1. Create resource groupaz group create --name rg-portal-dev --location eastus
# 2. Bootstrap Terraform (Phase 1 — run once)./scripts/bootstrap-state.sh
# 3. Initialize Terraform with the remote backendcd infra
terraform init
# 4. Dry run — review all 40+ resources before creating anythingterraform plan -var-file=env/dev.tfvars -out=dev.tfplan
# 5. Apply — ~15-20 minutes for first runterraform apply dev.tfplan
# 6. Seed Key Vault with application secretsKV_NAME=$(terraform output -raw kv_name)az keyvault secret set\ --vault-name $KV_NAME\--name"sql-connection-string"\--value"Server=tcp:$(terraform output -raw sql_fqdn),1433;Authentication=Active Directory Default;Database=portaldb;Encrypt=True;"az keyvault secret set\ --vault-name $KV_NAME\--name"ai-connection-string"\--value"$(terraform output -raw ai_connection_string)"# 7. Create SQL contained user for Managed IdentitySQL_SERVER=$(terraform output -raw sql_server_name)az sql db show-connection-string \--client ado.net \--server$SQL_SERVER\--name portaldb
# Connect as Azure AD admin and run:# CREATE USER [app-portal-api-dev] FROM EXTERNAL PROVIDER;# ALTER ROLE db_datareader ADD MEMBER [app-portal-api-dev];# ALTER ROLE db_datawriter ADD MEMBER [app-portal-api-dev];# 8. Trigger first pipeline rungit push origin main
# Pipeline picks up the push and runs Build → DEV
Phase 16 — Blue/Green Production Deployment
The PROD stage uses deployment slots for zero-downtime releases. Both app-portal-api-prod and app-portal-web-prod must have a staging slot created via Terraform:
resource "azurerm_linux_web_app_slot""api_staging"{name="staging"app_service_id= azurerm_linux_web_app.api.id
site_config{minimum_tls_version="1.2"ftps_state="Disabled"health_check_path="/health"application_stack{dotnet_version="8.0"}}# Slot-specific settings — staging uses a separate App Config labelapp_settings={"ASPNETCORE_ENVIRONMENT"="Staging""APPLICATIONINSIGHTS_CONNECTION_STRING"="@Microsoft.KeyVault(VaultName=${var.kv_name};SecretName=ai-connection-string)"}}
Swap rollback: If an issue is discovered after the swap, the old production build is now in the staging slot and can be immediately swapped back:
az webapp deployment slot swap \ --resource-group rg-portal-prod \--name app-portal-web-prod \--slot staging \ --target-slot production
Phase 17 — Validation Checklist
Run after every environment provisioning to confirm security baseline:
Check
Command
Expected
SQL public access disabled
az sql server show --name sql-portal-dev --resource-group rg-portal-dev --query publicNetworkAccess
"Disabled"
SQL DNS resolves via PE
nslookup sql-portal-dev.database.windows.net (from VM in VNet)
10.0.2.x
App Service HTTPS-only
az webapp show --name app-portal-api-dev --resource-group rg-portal-dev --query httpsOnly
true
FTPS disabled
az webapp config show --name app-portal-api-dev --resource-group rg-portal-dev --query ftpsState
"Disabled"
Key Vault public access off
az keyvault show --name kv-portal-dev --resource-group rg-portal-dev --query properties.publicNetworkAccess
"Disabled"
Managed identity set
az webapp identity show --name app-portal-api-dev --resource-group rg-portal-dev --query type
"SystemAssigned"
App Gateway backend healthy
az network application-gateway show-backend-health --name appgw-portal-dev --resource-group rg-portal-dev
All: Healthy
WAF in Prevention mode
az network application-gateway waf-policy show --name waf-policy-dev --resource-group rg-portal-dev --query policySettings.mode
"Prevention"
Telemetry flowing
App Insights Live Metrics → live request stream visible
# Destroy all resources (irreversible!)cd infra
terraform destroy -var-file=env/dev.tfvars
# Delete the resource group (catches anything Terraform missed)az group delete --name rg-portal-dev --yes# Remove the state file for this environmentaz storage blob delete \ --account-name saportalstate \ --container-name tfstate \--name portal-dev.tfstate
Key Vault note: Even after destroy, Key Vault enters a soft-deleted state for 90 days (because purge_protection_enabled = true). To reuse the same name before 90 days, purge it manually:
az keyvault purge --name kv-portal-dev --location eastus
Notes & Caveats
App Gateway subnet restriction: The appgw-subnet must be a dedicated subnet. App Gateway WAF_v2 cannot share a subnet with other resources, and it cannot use a subnet that has any NSG rule blocking ports 65200-65535 (Azure infrastructure communication range).
Managed Identity and Key Vault references: The @Microsoft.KeyVault(...) syntax in App Settings requires the App Service to have the Key Vault Secrets User role on the specific Key Vault. The role assignment can take 5-10 minutes to propagate. If the app starts before propagation completes, it will fail to read secrets — restart the app once the role is active.
Front Door + App Gateway SSL: You'll need a custom TLS certificate in Key Vault for the App Gateway. Front Door terminates TLS from the client, then re-encrypts to the App Gateway using the backend certificate. Make sure the App Gateway's SSL cert matches what Front Door expects as the backend hostname.
Zone redundancy in DEV: Zone-redundant SQL and App Service Plans are skipped in DEV/UAT (var.env == "prod" ? true : false) to save cost. Add zone redundancy to staging environments before load testing if the test simulates production topology.
azurerm provider v4 breaking changes: The v4 provider dropped support for virtual_network_subnet_id in several resources and renamed several attributes. If migrating from v3, run terraform plan after upgrading and review all planned changes before applying. The azurerm_app_service_virtual_network_swift_connection pattern used in Phase 7 is the v4-compatible approach.
OpenTofu compatibility: All HCL in this guide is compatible with OpenTofu 1.9+. Use tofu in place of terraform commands if your team is on the open-source fork.