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
azureSubscriptionIdis 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.ServicePrincipalIdinmcp.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
managementGroupIdmust appear as anidin<env-folder>/<targetRegions.primary of the spoke>/foundation.auto.tfvars, otherwise the config test rejects the file. The test accepts anyidinmanagement_group_structureand 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
ProductTeamGroupNamesmust 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
**/*.jsonneeds a/, so files placed directly inspokes/are not matched. - Use a unique file name across all sub-folders. The file name without
.jsonbecomes 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>.jsonmakesspokeGlobeasy to use. - The separate spoke canvas pipeline expects more: the name starts with
canvas_, ends with.jsonand contains_prod_or_nonprod_(for examplecanvas_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.tftakes the first one); - when the Key Vault or storage account is deployed (the default), every region in
targetRegionsneeds a VNet with such a subnet. The private endpoints are placed there. All fields, including the optionalkeyvault,storageaccount,loganalytics,azdoandextensionsblocks, 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
**/*.jsonunless 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'senvironmentslist (default dev → tst → acc → prd). Asharedspoke fails the run unless you addsharedtoenvironments.Make sure the entry pipeline does not have
whatIf: truewhen 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¶
- The pipeline's Apply step for the spoke environment is green.
- In the Azure portal, the subscription:
- is under
managementGroupId; - has the tags;
- has the virtual network with the expected address space and subnets.
- Connectivity subscription → Virtual WAN → Virtual network connections: the spoke VNet is listed as connected.
- Entra ID: the four
SG_SPOKE_<wl>_<env>_*groups exist, and the team group is an (eligible) member as configured. - 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. |