📋 In This Post
Why — The Problem This Solves
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.
What You Need to Know — Skills & Tools
- The Subscription Alias API — the only programmatic way to create a subscription on a Microsoft Customer Agreement. It is tenant-level, slow, and irreversible.
- Management group placement is a separate call. The alias API creates every subscription under the Tenant Root Group and does not move it. This is the single most important fact in the week.
- Cost Management budgets alert; they do not cap. Worth being precise about, because "the landing zone has a budget" is heard as a cap by everyone who did not build it.
- Azure RBAC at subscription scope — what a landing zone owner holds on day one, decided once, for every landing zone, forever.
- Subscription lifecycle — cancel, the retention window, and why decommission and delete are different words.
Architecture — How It Fits Together
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 |
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.

How We Built It — Step by Step
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.
Challenges — What Actually Went Wrong
Microsoft's own vending module could not be used at all
Azure/lz-vending/azurerm is the obvious choice for this week. It
is Microsoft's, it has 1.4 million downloads, and its inputs map exactly onto
what vending means. It is unusable here: its role_definitions and
cached_data submodules require
hashicorp/azurerm ~> 4.0, this lab standardised on
~> 5.2, and the two constraints have no overlap.
Could not retrieve the list of available versions for provider
hashicorp/azurerm: no available releases match the given constraints ~> 4.0, ~> 5.2
Not a version to negotiate — terraform init refuses to resolve at
all. Version 7.0.3 is the newest of its 37 releases and predates azurerm 5.
Checked with azurerm at 5.5.0. Last week's post was about what a version 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"
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.
Security — Controls at Every Layer
- 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.
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