📋 In This Post
Lab Overview
The bootstrap for this lab created three subscriptions by hand, one at a time, watching each one finish. That is fine for three. It is not fine for an organisation where a team asks for a landing zone on Tuesday and expects to deploy into it on Wednesday, and it is especially not fine as the answer to "which management group is this one in, and who has access?" — because the answer is whatever the person who clicked through the portal remembered to do.
Subscription vending is the pipeline version: a request goes in, and a landing zone comes out created, placed, budgeted and granted. No portal step, no case-by-case judgement, and the same decisions applied to the tenth landing zone as to the first.
What makes it a genuinely interesting week is not the creating. It is that a subscription is the only object in Azure this lab can create and cannot delete — so a vending pipeline has to have an answer for the end of a landing zone's life, and that answer cannot be "destroy it".
Vending is not one operation. It is four, across three different APIs, and only the first creates anything you would call a subscription. The other three are what turn an empty subscription into a landing zone — and the second one is the one pipelines forget.
| Difficulty | Advanced — four resources, but they span three APIs and a billing account |
| Time to complete | About an hour. Subscription creation is asynchronous and placement is not instant |
| Deployment | Terraform (azurerm), two stages, state in HCP Terraform, local execution |
| Measured cost | $0.00 — an empty subscription and a budget both cost nothing |
| Full lab guide | LAB.md — prerequisites, variables, commands, expected outputs, validation and cleanup |
Read this before running it. This week creates a
real Azure subscription, and a subscription cannot be
deleted — only cancelled, after which it lingers for a retention
window. vend_new_subscription defaults to false for
exactly that reason, and the cleanup section is about decommissioning rather
than removal.
Cloud Services and Concepts
Subscription vending is handing a team a new subscription that is already governed on arrival — placed in the hierarchy, budgeted, and with the right people holding the right roles — instead of an empty one and a wiki page. Five facts shape how:
- The Subscription Alias API is the only programmatic way to create a subscription on a Microsoft Customer Agreement. It is tenant-level, slow (minutes, not seconds), and not undoable.
- A management group is a folder for subscriptions, and it is where governance lives — policy and access granted on one are inherited by everything beneath it. A subscription sits in exactly one.
- Placement is a separate call. The alias API creates every subscription under the Tenant Root Group and does not move it. A subscription that was never moved inherits none of the policy you think governs it, and looks completely healthy until something is supposed to stop you and doesn't. This is the single most important fact in the week.
- A Cost Management budget alerts; it does not cap. Spend continues past it. Worth being precise about, because "the landing zone has a budget" is heard as a spending limit by everyone who did not build it.
- Azure RBAC at subscription scope is what a landing zone owner holds on day one — decided once, for every landing zone, more or less forever.
- Cancelling is not deleting. A cancelled subscription enters a retention window, stays visible, and cannot be removed on demand — which is why decommission and delete are different words.
Architecture and Workflow
Four operations, measured on the real thing:
| Operation | API | Measured | |
|---|---|---|---|
| 1 | create | Subscription Alias | 2m16s |
| 2 | place | Management Groups | 5s |
| 3 | budget | Consumption | 7s |
| 4 | grant | Authorization (RBAC) | 45s |
All four, in code. The alias call carries a 60-minute timeout because the provider default is shorter than Azure sometimes is:
In main.tf.
The budget and the grant are declared once and applied to every target, so "what does a landing zone get" has one answer that cannot drift between the rehearsal and the real thing:
In main.tf.
Step 2 is the one that gets skipped, and its absence is invisible
A subscription created but never placed is billed correctly, reports healthy, and appears in every list you would look at. It also inherits no policy and no role assignments from the tree it was supposed to land in. Nothing looks wrong until something that should have been stopped is not — which is the worst possible time to find out.
So placement is a separate Terraform resource here rather than an attribute, and the validation checks it from both directions: that the management group lists the subscription, and that the subscription reports the management group as its parent. One query answering correctly is not the same as the association existing.
Prerequisites
- A Microsoft Customer Agreement billing account, plus the
billing profile and invoice section the new subscription
bills to. All three are required variables with no defaults. They identify
your billing arrangement, so they live in
terraform.tfvars, which is gitignored, and never in a.tffile. - Rights to create subscriptions on that invoice section. This is a billing permission, separate from Entra and from Azure RBAC — being Owner of a tenant does not grant it.
- User Access Administrator (or Owner), because the lab creates a role assignment at subscription scope.
- Write access to the management group hierarchy the subscription is placed into. Week 01 builds one; any hierarchy works.
- Terraform ≥ 1.10 with
azurerm~> 5.2. Microsoft's ownAzure/lz-vending/azurermmodule cannot be used with it — see Troubleshooting. - Two resource providers:
Microsoft.ConsumptionandMicrosoft.CostManagement. - An individual billing account cannot use the DevTest rate.
The alias call refuses it outright, so the workload is
Productionwhether or not that is what you wanted.
Every variable and both stages are in the lab guide, which is the reproducible half of this write-up.
Implementation
0. How any of this gets deployed
All of it is Terraform. State lives in HCP Terraform, the apply runs locally
against whatever az login session is on the machine — HCP defaults a
new workspace to remote execution, which fails here because there are no
Azure credentials stored in the workspace.
terraform {
cloud {
organization = "Katta"
workspaces {
name = "azure-week-04-dev"
project = "Azure Platform Lab"
}
}
}
1. Two stages, one payload
./scripts/deploy.sh config # vend onto an existing subscription — repeatable
./scripts/deploy.sh vend # create a subscription too — one-way
config is the default, and that default is deliberate: the
destructive stage should be the one you have to ask for. It applies steps 2–4 to
a subscription that already exists, which is both the safe rehearsal and the
realistic case — a mature estate vends onto existing subscriptions far more often
than it creates new ones.
The payload is defined once and shared by both stages, so "what does a landing zone get" has a single answer that cannot drift between the rehearsal and the real thing. A $25 monthly budget with both an actual 80% alert and a forecast 100% alert — because an actual alert on a monthly budget arrives when 80% is already spent, and the forecast alert arrives while the month can still be changed.
Validation after the vend: 7 passed, 0 failed, every check read from Azure rather than from Terraform state — because state is a record of what Terraform believes it did.
Validation and Evidence
A successful apply proves an alias call returned. It does not prove the subscription landed in the right management group, that the budget attached, or that the grant is at the subscription rather than inherited from above — and that last distinction is most of what vending means.
| Test | Expected | Actual | Status |
|---|---|---|---|
| A budget exists on every vended target | a budget on each | 25.0 on both the existing and the vended subscription | PASS |
| The landing-zone grant exists at subscription scope | granted at the subscription, not inherited | 1 Reader on each, at the subscription itself | PASS |
| Placement, from the management group's side | the group lists the subscription | mg-lz-dev lists it | PASS |
| Placement, from the subscription's own side | its parent is that group | parent = .../managementGroups/mg-lz-dev | PASS |
| Deployed state matches configuration | no drift | none | PASS |
Checks 3 and 4 ask the same question from both ends, deliberately. A management group listing a child and a subscription naming its parent are stored separately, and a vending run that half-finishes can satisfy one without the other.
Security and Cost
Security
- The grant is Reader by default, and the default is the point. Vending decides what a landing zone owner holds on day one, for every landing zone. The interesting failure is not "they could not deploy" — that is a request away — it is a pipeline that hands out Owner on every subscription it creates because Owner was the value in the example.
- The grant is checked at subscription scope specifically, not with a query that would also match a grant inherited from a management group. An inherited grant is not something vending did.
- Placement is the security control. Policy inheritance flows from the management group; a subscription in the wrong place is a subscription outside your guardrails, whatever the assignments say.
- Budgets alert, they do not cap. Stated here because it is routinely misunderstood as a spending limit.
Cost
Nothing in this week has a per-hour price. A subscription costs nothing to exist, and a budget and a role assignment are free. The bill is whatever gets deployed into a landing zone, which is deliberately the next week's problem. $0 while running, $0 after.
Troubleshooting and Lessons
Microsoft's own vending module could not be used at all.
- Problem:
terraform initrefused to resolve at all —no available releases match the given constraints ~> 4.0, ~> 5.2. - Root cause:
Azure/lz-vending/azurermis the obvious choice: Microsoft's own, 1.4 million downloads, inputs that map exactly onto what vending means. Itsrole_definitionsandcached_datasubmodules requirehashicorp/azurerm ~> 4.0; this lab standardised on~> 5.2. The constraints have no overlap, and 7.0.3 — newest of its 37 releases — predates azurerm 5. Checked with azurerm at 5.5.0. - Resolution: write the four resources directly. Vending is an alias call, a management group association, a role assignment and a budget; the module's value is in everything it adds after that.
- Lesson learned: a version pin is a compatibility claim about the whole dependency tree, not a preference. Week 03's post was about what a pin does to a consumer; this week opened by being that consumer.
DevTest is not available on an individual billing account
The obvious choice for a lab is the cheaper DevTest rate. The alias call refuses it:
Code="NotAllowed" Message="Can't create DevTest Azure plans for individual
billing account. Please contact Azure Support." code: "InvalidSku"
The fix is one argument, and it is now a validated variable rather than a literal buried in the resource:
variable "subscription_workload" {
type = string
default = "Production" # DevTest is refused on an MCA Individual account
validation {
condition = contains(["Production", "DevTest"], var.subscription_workload)
error_message = "subscription_workload must be Production or DevTest."
}
}
Nothing in that message names the offending field. The only hint is the word
Sku in the error code, and the outer error is StatusCode=0
— the same signature as the client-side timeout this lab saw during bootstrap, so
the obvious first guess is "it timed out again" and the obvious first fix is to
raise the timeout, which does nothing.
The bootstrap never hit this because it omits workload entirely
and takes the Production default. Being more explicit than the working code is
what broke it.
A freshly vended subscription is invisible to the CLI
Immediately after vending, every query against the new subscription returned:
Subscription '<guid>' not found. Check the spelling and casing and try again.
Which reads as "vending failed". It had not — Terraform had created a budget
and a role assignment inside that subscription and returned their resource IDs.
The Azure CLI answers from a locally cached account list built at login, and a
subscription created minutes ago is not in it. az account list --refresh
fixes it. Three validation checks reported failure against resources that
demonstrably existed.
A related one, later: after cancellation the subscription listed as
Enabled while ARM refused writes on it as disabled. Trust the write
path, not the list.
Cleanup and Key Takeaways
Cleanup — the interesting half
The payload tears down completely: budgets and role assignments are ordinary
resources and terraform destroy removes them. Six destroyed.
The subscription does not. This is the constraint the whole week is shaped around:
- cancelling stops billing immediately
- the Delete option does not appear for three days
- Azure removes it automatically only after ninety days
So cleanup for a subscription means decommission, not delete: move it out of the landing zone tree, into the management group whose job is to refuse everything, and cancel it. The move matters more than the cancel — a subscription left in the landing zone tree with "we'll cancel it next week" attached is a subscription somebody deploys into.
The order matters, and I had it wrong
terraform destroy on azurerm_subscription
cancels the subscription. It does not merely drop it from state,
which is what I had assumed — the cleanup script said so in its own output. The
alias destroy took 1m39s, and afterwards ARM refused every write:
(ReadOnlyDisabledSubscription) The subscription ... is disabled and therefore
marked as read only.
Which meant the deny probe that ran next never reached policy evaluation. It
was refused for being cancelled, and proved nothing. The explicit cancel then
failed too, with NotAllowed — "Subscription is not in active state".
Move and probe first; destroy last.
The deny-all, finally proven
The decommissioned management group in this lab has carried an enforcing deny-all since the bootstrap, and it had never denied anything — the scope had no subscription in it, so there was nothing to attempt. It was the one control in the estate asserted rather than demonstrated.
With a subscription in scope, attempting to create a resource group:
(RequestDisallowedByPolicy) Resource 'rg-decomm-probe-001' was disallowed by
policy. Reasons: 'This subscription is decommissioned and is waiting out its
retention window. Nothing may be created in it. If you need these resources,
move the subscription out of the decommissioned management group first.'
The message is the one written into the policy at bootstrap, which is the point: a deny is only useful if the person who hits it is told what to do next.
One last detail worth knowing, because no document phrases it this way. A
cancelled subscription in its retention window does not report as
Disabled. It reports as Warned —
Azure's own word for "on its way out but not gone".
References
- Programmatically create Azure subscriptions
- Cancel and delete your Azure subscription
- What are Azure management groups?
- Create and manage Azure budgets
- Week 4 code in the lab repository
Key takeaways
- Vending is four operations, not one, and the second — placement — is the one that gets skipped. Its absence is invisible until a guardrail that should have fired does not.
- Check placement from both directions. The management group listing the subscription and the subscription reporting its parent are two different questions, and only one of them being right is a bug.
- Make the irreversible stage opt-in. The default should be the repeatable one. Anything that cannot be undone on the day should require somebody to type it.
- Design for the end of a landing zone's life, not just the start. You cannot delete a subscription on demand, so "decommissioned" has to be a real state with real enforcement rather than a note in a spreadsheet.
- A control you have never seen fire is not a control. That deny-all had been correct in every visible property for weeks, and completely untested, because nothing had ever been in scope for it.
Comments