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.tfvars file (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



Discover more from My Daily Cloud Blog

Subscribe to get the latest posts sent to your email.

Leave a comment

Trending