If you’re building an Azure environment for the first time (or rebuilding it correctly), you want a repeatable “core services” foundation: management groups, RBAC, hub-and-spoke networking, policies, logging/monitoring, backup, cost controls, and Defender for Cloud.
This guide includes Terraform you can copy into a repo and run. You’ll plug in your subscription IDs and region, then deploy a baseline foundation in a consistent way.
Prerequisites
- Azure tenant access (Entra ID)
- Permissions: Management Group + Subscription contributor/owner for the target scope
- Terraform 1.6+ installed
- Azure CLI installed and authenticated (
az login)
Repo Layout
azure-core-foundation/
versions.tf
providers.tf
variables.tf
main.tf
terraform.tfvars.example
modules/
management-groups/
rbac/
network-hub-spoke/
governance-policy/
monitoring/
backup/
cost-management/
defender/
Step 1: Management Groups + Subscription Organization (Terraform)
Terraform typically does not create Azure subscriptions. Instead, you create subscriptions (Portal / EA / MCA) and Terraform organizes them into management groups with consistent governance.
modules/management-groups/main.tf
resource "azurerm_management_group" "corp" {
display_name = var.mgmt_group_names.corp
}
resource "azurerm_management_group" "prod" {
display_name = var.mgmt_group_names.production
parent_management_group_id = azurerm_management_group.corp.id
}
resource "azurerm_management_group" "nonprod" {
display_name = var.mgmt_group_names.nonproduction
parent_management_group_id = azurerm_management_group.corp.id
}
resource "azurerm_management_group" "shared" {
display_name = var.mgmt_group_names.sharedservices
parent_management_group_id = azurerm_management_group.corp.id
}
resource "azurerm_management_group_subscription_association" "prod_assoc" {
management_group_id = azurerm_management_group.prod.id
subscription_id = var.subscription_ids.production
}
resource "azurerm_management_group_subscription_association" "nonprod_assoc" {
management_group_id = azurerm_management_group.nonprod.id
subscription_id = var.subscription_ids.nonproduction
}
resource "azurerm_management_group_subscription_association" "shared_assoc" {
management_group_id = azurerm_management_group.shared.id
subscription_id = var.subscription_ids.sharedservices
}
Step 2: IAM / RBAC Baseline (Terraform)
Create Entra ID security groups and assign baseline roles at the management group scope. This gives you repeatable access control aligned with least privilege.
modules/rbac/main.tf
resource "azuread_group" "readers" {
display_name = var.reader_group_name
security_enabled = true
}
resource "azuread_group" "contributors" {
display_name = var.contributor_group_name
security_enabled = true
}
resource "azurerm_role_assignment" "corp_readers" {
scope = var.scope_mgmt_group_id
role_definition_name = "Reader"
principal_id = azuread_group.readers.object_id
}
resource "azurerm_role_assignment" "corp_contributors" {
scope = var.scope_mgmt_group_id
role_definition_name = "Contributor"
principal_id = azuread_group.contributors.object_id
}
Step 3: Core Networking (Hub-and-Spoke) (Terraform)
This creates a hub VNet, two spoke VNets, subnets, and bi-directional VNet peering. It’s a clean baseline you can expand with Azure Firewall, Bastion, VPN Gateway, Private DNS, NSGs, and UDRs.
modules/network-hub-spoke/main.tf
resource "azurerm_resource_group" "rg" {
name = var.resource_group_name
location = var.location
tags = var.tags
}
resource "azurerm_virtual_network" "hub" {
name = "${var.resource_group_name}-hub-vnet"
location = var.location
resource_group_name = azurerm_resource_group.rg.name
address_space = [var.hub_vnet_cidr]
tags = var.tags
}
resource "azurerm_subnet" "hub_subnets" {
for_each = var.hub_subnets
name = each.key
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = [each.value]
}
resource "azurerm_virtual_network" "spokes" {
for_each = var.spoke_vnets
name = "${var.resource_group_name}-${each.key}-spoke-vnet"
location = var.location
resource_group_name = azurerm_resource_group.rg.name
address_space = [each.value.cidr]
tags = var.tags
}
locals {
spoke_subnet_map = merge([
for vnet_key, vnet in var.spoke_vnets : {
for sn_key, sn_cidr in vnet.subnets :
"${vnet_key}.${sn_key}" => {
vnet_key = vnet_key
name = sn_key
cidr = sn_cidr
}
}
]...)
}
resource "azurerm_subnet" "spokes" {
for_each = local.spoke_subnet_map
name = each.value.name
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.spokes[each.value.vnet_key].name
address_prefixes = [each.value.cidr]
}
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
for_each = azurerm_virtual_network.spokes
name = "peer-hub-to-${each.key}"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.hub.name
remote_virtual_network_id = each.value.id
allow_virtual_network_access = true
allow_forwarded_traffic = true
}
resource "azurerm_virtual_network_peering" "spoke_to_hub" {
for_each = azurerm_virtual_network.spokes
name = "peer-${each.key}-to-hub"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = each.value.name
remote_virtual_network_id = azurerm_virtual_network.hub.id
allow_virtual_network_access = true
allow_forwarded_traffic = true
}
Step 4: Security & Governance (Azure Policy) (Terraform)
This enforces allowed regions and mandatory tags at the management group scope, preventing common misconfigurations early.
modules/governance-policy/main.tf
resource "azurerm_policy_definition" "allowed_locations" {
name = "allowed-locations"
policy_type = "Custom"
mode = "All"
display_name = "Allowed locations"
policy_rule = jsonencode({
if = {
not = {
field = "location"
in = "[parameters('listOfAllowedLocations')]"
}
}
then = { effect = "Deny" }
})
parameters = jsonencode({
listOfAllowedLocations = {
type = "Array"
metadata = { displayName = "Allowed locations" }
}
})
}
resource "azurerm_policy_assignment" "allowed_locations" {
name = "pa-allowed-locations"
scope = var.mgmt_group_id_corp
policy_definition_id = azurerm_policy_definition.allowed_locations.id
parameters = jsonencode({
listOfAllowedLocations = { value = var.allowed_locations }
})
}
resource "azurerm_policy_definition" "require_tags" {
name = "require-tags"
policy_type = "Custom"
mode = "Indexed"
display_name = "Require resource tags"
policy_rule = jsonencode({
if = {
anyOf = [
for t in var.required_tags : {
field = "tags[${t}]"
exists = "false"
}
]
}
then = { effect = "Deny" }
})
}
resource "azurerm_policy_assignment" "require_tags" {
name = "pa-require-tags"
scope = var.mgmt_group_id_corp
policy_definition_id = azurerm_policy_definition.require_tags.id
}
Step 5: Monitoring & Logging (Log Analytics) (Terraform)
modules/monitoring/main.tf
resource "azurerm_resource_group" "rg" {
name = var.resource_group_name
location = var.location
tags = var.tags
}
resource "azurerm_log_analytics_workspace" "law" {
name = var.law_name
location = var.location
resource_group_name = azurerm_resource_group.rg.name
sku = "PerGB2018"
retention_in_days = 30
tags = var.tags
}
Step 6: Backup & Recovery (Recovery Services Vault) (Terraform)
modules/backup/main.tf
resource "azurerm_resource_group" "rg" {
name = var.resource_group_name
location = var.location
tags = var.tags
}
resource "azurerm_recovery_services_vault" "rsv" {
name = var.rsv_name
location = var.location
resource_group_name = azurerm_resource_group.rg.name
sku = "Standard"
soft_delete_enabled = true
tags = var.tags
}
Step 7: Cost Controls (Budgets + Alerts) (Terraform)
modules/cost-management/main.tf
resource "azurerm_consumption_budget_subscription" "budget" {
name = "monthly-budget"
subscription_id = var.subscription_id
amount = var.monthly_budget
time_grain = "Monthly"
time_period {
start_date = "2025-01-01T00:00:00Z"
end_date = "2035-01-01T00:00:00Z"
}
notification {
enabled = true
threshold = 80
operator = "GreaterThan"
contact_emails = var.emails
}
notification {
enabled = true
threshold = 100
operator = "GreaterThan"
contact_emails = var.emails
}
}
Optional: Defender for Cloud Baseline (Terraform)
modules/defender/main.tf
provider "azurerm" {
alias = "sub"
features {}
subscription_id = var.subscription_id
}
resource "azurerm_security_center_subscription_pricing" "vm" {
provider = azurerm.sub
tier = "Standard"
resource_type = "VirtualMachines"
}
Run It
- Create a
terraform.tfvarsfile (example below) - Run:
terraform init - Run:
terraform plan - Run:
terraform apply
For all Code Files, visit the following GitHub Repository:
https://github.com/mbtechgru/Azure_Core_Services.git





Leave a comment