Skip to content

New Spoke (Workload / Application Landing Zone)

Standard change: New workload/spoke/application landing zone (spoke canvas)

Effect: an existing Azure subscription becomes a ready-to-use landing zone. It is moved into the chosen management group, tagged and renamed, connected to the hub, and gets the network, identity and RBAC baseline described below.

The reviewer and operator runbooks are Spoke creation, Spoke pull request review and Spoke deployment.

Request fields

Request field Where it goes in the spoke JSON Notes
Name workloadShortName, plus azureSubscriptionName and the file name workloadShortName is at most 6 characters. It is used in every resource name and in the Terraform state key. Choose it carefully: changing it later creates a new spoke.
Rationale pull request / change record
Environment environment dev, tst/test, acc/acc<n>, prd/prod, shrd/shared. The pipeline normalizes the value only to group spokes into stages (acc2 → acc, prod → prd). Names and the state key use the raw value, so prod and prd would be two different spokes. Pick one spelling and stick to it.
Usage type (production data yes/no) managementGroupId, and the pii tag if personal data is involved The JSON has no production-data field. Express it by choosing a management group with the right policies, for example a …-prdlike child group rather than …-nonprd, if your structure has them.
Exposure to public internet yes/no managementGroupId (landingzones_corp vs landingzones_online tree) and InternetSecurityEnabled Inbound publishing goes through the hub Application Gateway (runbook). Outbound internet needs firewall rules (runbook).
IP range / IP size resourceconfiguration.network[].addressPrefix Allocate from your IP plan. The code does not check for overlaps. A /24 is a common default. For more, agree on it with whoever owns the IP plan. Record the allocated range in the plan in the same pull request.
Subnet name / size network[].subnets[].name / .addressPrefix
Subnet delegations network[].subnets[].delegations[] name must be one of the values listed in the schema (for example Microsoft.Web/serverFarms).
Subnet service endpoints network[].subnets[].serviceEndpoints[] { "service": "Microsoft.Storage" }
Which team needs access adInfo.ProductTeamGroupNames, adInfo.PIMGroupConfig Existing Entra ID group display names.
Comments –

Check the request

When a request arrives, check first that the information is complete and correct. Send unclear requests back until they are clear.

Item Check
Workload short name Unique across all spokes. At most 6 characters. Letters and digits only: no spaces or special characters.
Environment One of the supported lifecycle phases (development, test, acceptance, production). Use the abbreviations dev, tst, acc, prd consistently.
Tags The required governance tags are present: owner, department, costcentre, pii (a text value, see the reference), plus workload and environment. The spoke canvas guide asks for lowercase values; check what your tag policy requires. Do not add the reserved tag hidden-XebiaSpokeMetadata: the deployment adds it.
Team group The name of the product team group, exactly as it is in Entra ID.
Target regions Regions that exist in your platform. Use the short names of your regioninfo (Spoke common settings). Example short names: weu (West Europe), neu (North Europe).
Address space A /24 (256 addresses) is the common minimum. Anything larger than a /23 needs a discussion and an explanation. See also Network IP sizing.
Subnets Name, size, delegations and service endpoints, per Spoke JSON reference.
Management group Chosen from the groups in your structure, see below.

Choosing the management group

The spoke goes into a landing zone management group. Two choices decide which one: whether the workload is reachable from the internet (Corp or Online) and whether it handles production-like data (PrdLike or NonPrd). With a canary and a production platform environment, each has its own tree, so there are eight combinations in total:

Platform environment Corp or Online NonPrd or PrdLike Management group (example naming)
Canary Corp NonPrd <canary>CorpNonPrd
Canary Corp PrdLike <canary>CorpPrdLike
Canary Online NonPrd <canary>OnlineNonPrd
Canary Online PrdLike <canary>OnlinePrdLike
Production Corp NonPrd <platform>CorpNonPrd
Production Corp PrdLike <platform>CorpPrdLike
Production Online NonPrd <platform>OnlineNonPrd
Production Online PrdLike <platform>OnlinePrdLike

The names are an example of one implementation. Use the IDs that exist in your management_group_structure (Hub config).

Before you start

For the first spoke of a new platform, see Deploy the spoke canvas.

  • The subscription must already exist. The subscription layer can create a subscription, but only when azureSubscriptionId is empty, and only with a CSP billing account (xmscsp_billing_account_name). The pipeline passes the CSP values as empty strings (Invoke-XebiaMcpSpokePlan.ps1), so through the pipelines you must provide an existing subscription ID. Create the subscription through your EA/MCA/CSP process first.
  • The spoke deployment identity (Azure.TerraformState.Spoke.ServicePrincipalId in mcp.json, used by the spoke service connection) must be able to do everything listed under What gets created:
  • move the subscription between management groups;
  • create role assignments on the subscription;
  • create a virtual hub connection in the connectivity subscription;
  • create Entra ID groups in the administrative unit SpokeCommon.tenantinfo.AdministrativeUnitId;
  • create service principals;
  • configure PIM for groups.
  • The target managementGroupId must appear as an id in <env-folder>/<targetRegions.primary of the spoke>/foundation.auto.tfvars, otherwise the config test rejects the file. The test accepts any id in management_group_structure and in the corp/online child maps. Choose a landing zone group yourself; the test will not stop you from picking, for example, the sandbox group.
  • The groups in ProductTeamGroupNames must exist in Entra ID.
  • The approvals your organisation requires are in place, for example the product owner and architecture and security for a new workload, and a registration of the workload in your asset register (CMDB) if you keep one.
  • The address range is reserved in the IP plan (see the table above) and the subnet plan leaves room for new subnets. If you use an IPAM pool instead, the pool is listed in the common settings (below).
  • If the spoke creates an Azure DevOps repository or a GitHub repository, the preparation for that is done (Prepare spoke integrations).

Steps

1. Create the spoke file

Create a branch first. A standard branch name makes branches easy to find, for example feature/SpokeNew-<request id>-<workload short name>-<environment>. Check your own conventions.

Then create <env-folder>/spokes/<team-or-workload>/<file>.json. The easiest start is a copy of the template spoke file that comes with the configuration repository or the code version you pin. Template files for common patterns can be kept in the same repository, but give them an extension other than .json, or put them where the glob does not match, so that the pipeline does not deploy them.

  • Use a sub-folder. The default glob **/*.json needs a /, so files placed directly in spokes/ are not matched.
  • Use a unique file name across all sub-folders. The file name without .json becomes the spoke's name in pipeline jobs and artifacts (spoke_<name>, subscription_output_<name>). Two files with the same name in different folders collide in one run.
  • A consistent pattern such as canvas_<workload>_<environment>.json makes spokeGlob easy to use.
  • The separate spoke canvas pipeline expects more: the name starts with canvas_, ends with .json and contains _prod_ or _nonprod_ (for example canvas_nonprod_<workload>-<environment>.json). Follow this pattern if you run that pipeline.

Minimal example (valid against src/Xebia.Mcp/Files/json/spokeschema.json):

{
  "workloadShortName": "app1",
  "environment": "dev",
  "azureSubscriptionId": "00000000-0000-0000-0000-000000000000",
  "azureSubscriptionName": "sub-app1-dev",
  "managementGroupId": "cloudplatform-landingzones-corp",
  "tags": {
    "owner": "Jane Doe",
    "department": "Finance IT",
    "workload": "Invoice Portal",
    "pii": "no",
    "costcentre": "CC-1234",
    "environment": "dev"
  },
  "adInfo": {
    "ProductTeamGroupNames": ["Team Invoice Portal"],
    "PIMGroupConfig": {
      "Readers": "nopim",
      "Contributors": "self-approve",
      "Owners": "approval"
    }
  },
  "targetRegions": {
    "primary": "weu",
    "additional": []
  },
  "resourceconfiguration": {
    "network": [
      {
        "name": "vnet-##wlshortname##-##environment##-##region##-001",
        "region": "weu",
        "addressPrefix": ["10.20.4.0/24"],
        "peerHub": true,
        "dnsServerOverwrite": "",
        "subnets": [
          {
            "name": "snet-##wlshortname##-pe-##environment##-##region##-001",
            "isprivatelinksubnet": true,
            "addressPrefix": "10.20.4.0/27",
            "privateEndpointNetworkPoliciesEnabled": false,
            "serviceEndpoints": [],
            "delegations": [],
            "linkedroutetablename": null,
            "linkednetworksecuritygroupname": "nsg-##wlshortname##-##environment##-##region##-001"
          },
          {
            "name": "snet-##wlshortname##-app-##environment##-##region##-001",
            "isprivatelinksubnet": false,
            "addressPrefix": "10.20.4.64/26",
            "privateEndpointNetworkPoliciesEnabled": false,
            "serviceEndpoints": [{ "service": "Microsoft.Storage" }],
            "delegations": [
              {
                "name": "Microsoft.Web/serverFarms",
                "actions": ["Microsoft.Network/virtualNetworks/subnets/action"]
              }
            ],
            "linkedroutetablename": null,
            "linkednetworksecuritygroupname": "nsg-##wlshortname##-##environment##-##region##-001"
          }
        ],
        "networksecuritygroups": [
          { "name": "nsg-##wlshortname##-##environment##-##region##-001", "rules": [] }
        ],
        "routetables": []
      }
    ]
  }
}

Placeholders ##wlshortname##, ##environment## and ##region## are replaced in network, subnet, NSG and route table names.

Two structural rules that the schema does not enforce, but the Terraform relies on:

  • every network[] entry needs at least one subnet with "isprivatelinksubnet": true (modules/virtual_network/locals.tf takes the first one);
  • when the Key Vault or storage account is deployed (the default), every region in targetRegions needs a VNet with such a subnet. The private endpoints are placed there. All fields, including the optional keyvault, storageaccount, loganalytics, azdo and extensions blocks, are described in the Spoke JSON reference.

Address space from an IPAM pool

If the platform has IPAM pools (Spoke common settings), you can leave out addressPrefix and give the pool name and the sizes instead. The pool picks the addresses, and the overlap risk of hand-written ranges disappears. The fields and rules are in the Spoke JSON reference. Record the allocated ranges from the deployment output in your IP plan afterwards.

2. Decide on the optional resources

If you leave out the storageaccount, keyvault and loganalytics blocks, a storage account, a Key Vault and a Log Analytics workspace are still created with module defaults (deploy defaults to true in spoke-canvas/deploy/spoke/locals.tf and main.tf). A storage account always forces a Key Vault, which holds its customer-managed key. To skip them, add the block with "deploy": false and all keys the schema requires for that block (see the reference).

Only create the private endpoint of the Key Vault or the storage account (createprivateendpoint, CreatePrivateEndpoint) if the workload will use it. The Key Vault holds the secret of the deployment service principal unless the spoke uses federated credentials. Prefer federated credentials (azdo.useFederatedCredentials when you create the Azure DevOps service connection, or the GitHub extension), so that no secret needs to be stored or rotated. Both integrations are described in Spoke integrations.

3. Validate and open a pull request

Review the file once more, commit it to the branch and open a pull request. Another person reviews it (Spoke pull request review). Follow Making a change safely, steps 4–5.

4. Run the spoke pipeline

Run spoke with spokeGlob set to the new file only, for example team-a/canvas_app1_dev.json.

  • Azure DevOps: Pipelines → spoke → Run pipeline → spokeGlob = team-a/canvas_app1_dev.json.
  • GitHub Actions: Actions → spoke → Run workflow → spokeGlob = team-a/canvas_app1_dev.json.

Don't run with the default **/*.json unless you intend to plan every spoke. Every spoke file matched by the glob must pass the config tests, and its environment must be in the pipeline's environments list (default dev → tst → acc → prd). A shared spoke fails the run unless you add shared to environments.

Make sure the entry pipeline does not have whatIf: true when you want the spoke applied (see Pipelines and workflows).

What gets created

From src/Xebia.Mcp/Files/terraform/spoke-canvas/deploy/spoke/main.tf:

Resource Name / detail
Subscription tags The JSON tags plus spoke_canvas_version
Management group placement Moves the subscription to managementGroupId, if it is not already there
Subscription rename To azureSubscriptionName. If that is empty, to SpokeCommon.tenantinfo.SubsciptionNamingTemplate
Resource group (per region) rg-canvas-<wl>-<env>-<region>-001, unless resourceGroup.name is set
Virtual network, subnets, NSGs, rules, route tables From network[]. Every subnet gets an NSG: the one in linkednetworksecuritygroupname, or a default NSG
DNS servers on the virtual network dnsServerOverwrite, or the hub DNS server of the region (virtual_network_dns_servers in the hub connectivity tfvars)
Virtual hub connection (if peerHub, default true) VNet name with vnet- → con-, in the connectivity subscription, internet_security_enabled = InternetSecurityEnabled (default true)
Log Analytics workspace (default on) log-<wl>-<env>-<region>-001
Key Vault (default on) kv<wl><env><region>001, RBAC mode, private endpoint by default
Storage account (default on) st<wl><env><region>001, customer-managed key, private endpoint by default
Entra ID groups <GroupnamePrefix><wl>_<env>_Owners, _Contributors, _Readers, _Support in the administrative unit
PIM Each ProductTeamGroupNames group becomes a member of each spoke group: nopim = active, self-approve = eligible without approval, approval = eligible with approval
Service principal <SPNNamePrefix><wl>-<env>. Its secret is stored in the spoke Key Vault unless federated credentials are used
Role assignments Subscription: SPN and Owners group → Owner, Contributors → Contributor, Readers → Reader. Plus data-plane roles on the Key Vault and storage account, granted through the role map at subscription scope (RBAC and PIM)
Azure DevOps repo and service connection Only when the azdo block is present
GitHub repository and federated credentials Only when extensions.github is enabled (Spoke integrations)

Not created by the spoke canvas: budgets, and firewall rules for the spoke's traffic.

Verify

  1. The pipeline's Apply step for the spoke environment is green.
  2. In the Azure portal, the subscription:
  3. is under managementGroupId;
  4. has the tags;
  5. has the virtual network with the expected address space and subnets.
  6. Connectivity subscription → Virtual WAN → Virtual network connections: the spoke VNet is listed as connected.
  7. Entra ID: the four SG_SPOKE_<wl>_<env>_* groups exist, and the team group is an (eligible) member as configured.
  8. Ask the team to activate their role (if PIM applies) and confirm access.

Roll back

Remove the spoke with the Delete spoke runbook. Deleting the JSON file alone does nothing: no pipeline destroys spokes whose file is missing.

If the spoke gets managed VMs, add its folder to the list of spokes of the module pipeline (see Managed VM module).

Common problems

Symptom Cause
Config test: environment … must be one of The spoke environment is not in the pipeline environments list.
Config test: managementGroupId … must exist The group is not in the primary region foundation.auto.tfvars.
Config test: schema error Missing required key. Every subnet needs isprivatelinksubnet, privateEndpointNetworkPoliciesEnabled, serviceEndpoints, delegations and linkedroutetablename.
Plan wants to create a whole new spoke for an existing one workloadShortName or environment changed, which changes the state key.
A re-run plans to destroy azurerm_management_group_subscription_association Known issue: the association's count depends on whether the subscription is already in the group. See Known issues. Don't apply that destroy without understanding it.
Spoke can't reach the internet or other networks Firewall rules are missing. See Firewall rules.