About subscription vending
Every enterprise Azure deployment eventually hits the same scaling problem: the platform team becomes the bottleneck for subscription creation. Every new project, every new workload needs a subscription, and if that requires a human to manually create it, wire up networking, apply policies, configure logging, and set up RBAC, the queue backs up fast. I've worked on engagements where teams were waiting two weeks for a subscription. Subscription vending is the fix: a YAML file, a pull request, and a pipeline that does the rest.
┌─────────────────────────────────────────────────────────┐
│ Subscription Vending Flow │
└─────────────────────────────────────────────────────────┘
Team Submits Request (GitHub Issue / ServiceNow)
│
▼
┌───────────────────────────┐
│ Validate Request │ ← Check approvals, quotas
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ Create Subscription │ ← Azure Billing API
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ Apply Baseline Config │ ← Terraform/Bicep module
│ - Management group │
│ - Azure policies │
│ - Networking (VNet) │
│ - Logging integration │
│ - Budget alerts │
│ - RBAC roles │
└───────────┬───────────────┘
│
▼
┌───────────────────────────┐
│ Notify Team │ ← Subscription details
└───────────────────────────┘
When I start building this
I start building subscription vending infrastructure when the team is regularly creating more than one or two subscriptions per month, or when I can see that growth will get there within six months. Before that threshold, the overhead of building and maintaining the pipeline isn't justified, and a simple PR-based process with a checklist is enough. Once the volume is there, the automation pays for itself quickly.
Subscription types (product lines)
I define product lines for different subscription categories:
| Product Line | Purpose | Connectivity | Policies | Budget | Use Cases |
|---|---|---|---|---|---|
| Sandbox | Experimentation | None | Relaxed | Low ($500/mo) | Learning, POCs, testing |
| Development | Dev/test workloads | Optional | Moderate | Medium ($2K/mo) | Non-prod applications |
| Corp-Connected | Production apps | Hub VNet | Strict | High ($10K/mo) | Enterprise apps, databases |
| Online | Internet-facing | No hub | Strict | High ($10K/mo) | Public websites, SaaS |
| Data & Analytics | Big data platforms | Optional | Data governance | Variable | Synapse, Databricks, Data Lake |
Example: sandbox subscription configuration
Characteristics:
- No production data allowed
- Auto-delete resources >30 days old
- Limited VM sizes (Standard_B2s only)
- No VNet peering
- Lower quotas
# Terraform: Sandbox subscription vending
module "sandbox_subscription" {
source = "Azure/lz-vending/azurerm"
version = "~> 4.0"
subscription_alias_enabled = true
subscription_billing_scope = var.billing_account_id
subscription_display_name = "sub-${var.team_name}-sandbox"
subscription_alias_name = "sub-${var.team_name}-sandbox"
subscription_workload = "DevTest"
# Place in Sandbox management group
subscription_management_group_id = "mg-sandbox"
# Networking: Isolated VNet
virtual_network_enabled = true
virtual_networks = {
vnet1 = {
name = "vnet-${var.team_name}-sandbox"
address_space = ["10.100.0.0/24"]
location = var.location
resource_group_name = "rg-${var.team_name}-networking-sandbox"
subnets = {
default = {
address_prefixes = ["10.100.0.0/26"]
}
}
}
}
# Role assignments
role_assignment_enabled = true
role_assignments = {
owner = {
principal_id = var.team_ad_group_id
definition = "Owner"
relative_scope = ""
}
}
# Resource tags
subscription_tags = {
Environment = "Sandbox"
Team = var.team_name
CostCenter = var.cost_center
AutoDelete = "30days"
DataClassification = "Internal"
}
}
Example: production corp-connected subscription
Characteristics:
- Hub VNet peering
- Strict Azure policies
- No public IPs allowed
- Mandatory encryption
- Higher VM quotas
# Terraform: Production subscription vending
module "prod_subscription" {
source = "Azure/lz-vending/azurerm"
version = "~> 4.0"
subscription_alias_enabled = true
subscription_billing_scope = var.billing_account_id
subscription_display_name = "sub-${var.team_name}-prod"
subscription_alias_name = "sub-${var.team_name}-prod"
subscription_workload = "Production"
# Place in Corp management group
subscription_management_group_id = "mg-landing-zones-corp"
# Hub-connected networking
virtual_network_enabled = true
virtual_networks = {
spoke = {
name = "vnet-${var.team_name}-prod"
address_space = ["10.10.0.0/16"]
location = var.location
resource_group_name = "rg-${var.team_name}-networking-prod"
subnets = {
app = {
address_prefixes = ["10.10.1.0/24"]
}
data = {
address_prefixes = ["10.10.2.0/24"]
}
privatelink = {
address_prefixes = ["10.10.3.0/24"]
}
}
# Peer to hub VNet
vnet_peering = {
to_hub = {
name = "peer-to-hub"
remote_virtual_network_id = var.hub_vnet_id
allow_forwarded_traffic = true
use_remote_gateways = true
}
}
}
}
# Role assignments
role_assignment_enabled = true
role_assignments = {
contributor = {
principal_id = var.team_ad_group_id
definition = "Contributor"
relative_scope = ""
}
reader = {
principal_id = var.security_team_ad_group_id
definition = "Reader"
relative_scope = ""
}
}
# Resource tags
subscription_tags = {
Environment = "Production"
Team = var.team_name
CostCenter = var.cost_center
DataClassification = "Confidential"
SLA = "99.9%"
}
}
# Budget alert
resource "azurerm_consumption_budget_subscription" "prod" {
name = "budget-${var.team_name}-prod"
subscription_id = module.prod_subscription.subscription_id
amount = 10000
time_grain = "Monthly"
time_period {
start_date = "2024-01-01T00:00:00Z"
}
notification {
enabled = true
threshold = 80
operator = "GreaterThan"
threshold_type = "Forecasted"
contact_emails = [
var.team_email,
"finance@example.com"
]
}
notification {
enabled = true
threshold = 100
operator = "GreaterThan"
contact_emails = [
var.team_email,
"finance@example.com"
]
}
}
Request process
Option 1: GitHub issues (simple)
Create a GitHub repository for subscription requests:
1. Issue Template
<!-- .github/ISSUE_TEMPLATE/subscription-request.yml -->
name: Subscription Request
description: Request a new Azure subscription
title: "[SUB REQUEST] <Team Name> - <Environment>"
labels: ["subscription-request", "pending-approval"]
body:
- type: input
id: team-name
attributes:
label: Team Name
description: Name of the team requesting the subscription
placeholder: "platform-team"
validations:
required: true
- type: dropdown
id: product-line
attributes:
label: Subscription Type
description: Select subscription product line
options:
- Sandbox
- Development
- Corp-Connected Production
- Online Production
- Data & Analytics
validations:
required: true
- type: input
id: cost-center
attributes:
label: Cost Center
placeholder: "CC-12345"
validations:
required: true
- type: input
id: budget
attributes:
label: Monthly Budget (USD)
placeholder: "5000"
validations:
required: true
- type: textarea
id: justification
attributes:
label: Business Justification
description: Why is this subscription needed?
validations:
required: true
- type: input
id: owner-email
attributes:
label: Subscription Owner Email
placeholder: "owner@example.com"
validations:
required: true
- type: checkboxes
id: acknowledgment
attributes:
label: Acknowledgment
options:
- label: I understand this subscription will inherit Azure policies from the management group
required: true
- label: I will tag all resources appropriately
required: true
- label: I will monitor costs and stay within budget
required: true
2. GitHub Actions Workflow
# .github/workflows/subscription-vending.yml
name: Subscription Vending
on:
issues:
types: [labeled]
permissions:
issues: write
id-token: write
contents: read
jobs:
provision-subscription:
if: contains(github.event.issue.labels.*.name, 'approved')
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Parse Issue
id: parse
uses: actions/github-script@v7
with:
script: |
const body = context.payload.issue.body;
// Extract values from issue body (simplified)
const teamName = body.match(/Team Name:\s*(.+)/)[1].trim();
const productLine = body.match(/Subscription Type:\s*(.+)/)[1].trim();
const costCenter = body.match(/Cost Center:\s*(.+)/)[1].trim();
const budget = body.match(/Monthly Budget.*:\s*(.+)/)[1].trim();
core.setOutput('team_name', teamName);
core.setOutput('product_line', productLine);
core.setOutput('cost_center', costCenter);
core.setOutput('budget', budget);
- name: Azure Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
- name: Terraform Init
run: terraform init
working-directory: ./subscription-vending
- name: Terraform Apply
run: |
terraform apply -auto-approve \
-var="team_name=${{ steps.parse.outputs.team_name }}" \
-var="product_line=${{ steps.parse.outputs.product_line }}" \
-var="cost_center=${{ steps.parse.outputs.cost_center }}" \
-var="budget=${{ steps.parse.outputs.budget }}"
working-directory: ./subscription-vending
- name: Get Subscription ID
id: subscription
run: |
SUB_ID=$(terraform output -raw subscription_id)
echo "subscription_id=$SUB_ID" >> $GITHUB_OUTPUT
working-directory: ./subscription-vending
- name: Comment on Issue
uses: actions/github-script@v7
with:
script: |
const comment = `✅ **Subscription Provisioned Successfully**
**Subscription ID:** \`${{ steps.subscription.outputs.subscription_id }}\`
**Team:** ${{ steps.parse.outputs.team_name }}
**Type:** ${{ steps.parse.outputs.product_line }}
**Next Steps:**
1. Access the subscription: [Azure Portal](https://portal.azure.com/#@example.com/resource/subscriptions/${{ steps.subscription.outputs.subscription_id }})
2. Review applied policies
3. Start deploying resources
**Important:**
- Budget alerts configured for ${{ steps.parse.outputs.budget }} USD/month
- All resources must be tagged with \`Team\`, \`Environment\`, \`CostCenter\`
- Monitor costs daily: [Cost Management](https://portal.azure.com/#blade/Microsoft_Azure_CostManagement/Menu/costanalysis)`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
github.rest.issues.update({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
state: 'closed',
labels: ['provisioned']
});
Option 2: ServiceNow or an internal portal (enterprise)
On larger engagements, I integrate with the enterprise service catalog:
Workflow:
- User submits request via ServiceNow
- Approval workflow (manager + platform team)
- ServiceNow triggers webhook to Azure DevOps pipeline
- Pipeline runs Terraform to provision subscription
- ServiceNow updated with subscription details
Baseline configuration
Every vended subscription gets:
1. Management group assignment
resource "azurerm_management_group_subscription_association" "example" {
management_group_id = var.management_group_id
subscription_id = module.subscription.subscription_id
}
2. Azure Policies
# Example policies applied at management group level (inherited)
- Require specific tags
- Enforce naming conventions
- Deny public IPs (production only)
- Require encryption at rest
- Allowed VM sizes
- Allowed locations
3. Diagnostic settings
# Send all logs to central Log Analytics
resource "azurerm_monitor_diagnostic_setting" "subscription" {
name = "diag-subscription-logs"
target_resource_id = "/subscriptions/${module.subscription.subscription_id}"
log_analytics_workspace_id = var.central_log_analytics_id
enabled_log {
category = "Administrative"
}
enabled_log {
category = "Security"
}
enabled_log {
category = "Policy"
}
metric {
category = "AllMetrics"
enabled = false
}
}
4. Resource groups
# Pre-create standard resource groups
resource "azurerm_resource_group" "networking" {
name = "rg-${var.team_name}-networking-${var.environment}"
location = var.location
}
resource "azurerm_resource_group" "compute" {
name = "rg-${var.team_name}-compute-${var.environment}"
location = var.location
}
resource "azurerm_resource_group" "data" {
name = "rg-${var.team_name}-data-${var.environment}"
location = var.location
}
5. Networking
# VNet with standardized subnets
resource "azurerm_virtual_network" "main" {
name = "vnet-${var.team_name}-${var.environment}"
address_space = var.vnet_cidr
location = var.location
resource_group_name = azurerm_resource_group.networking.name
tags = var.tags
}
# Network Security Groups
resource "azurerm_network_security_group" "app" {
name = "nsg-app-${var.environment}"
location = var.location
resource_group_name = azurerm_resource_group.networking.name
# Baseline security rules
security_rule {
name = "DenyAllInbound"
priority = 4096
direction = "Inbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
6. RBAC assignments
# Team gets Contributor
resource "azurerm_role_assignment" "team_contributor" {
scope = "/subscriptions/${module.subscription.subscription_id}"
role_definition_name = "Contributor"
principal_id = var.team_ad_group_id
}
# Security team gets Reader
resource "azurerm_role_assignment" "security_reader" {
scope = "/subscriptions/${module.subscription.subscription_id}"
role_definition_name = "Reader"
principal_id = var.security_team_ad_group_id
}
# Finance team gets Cost Management Reader
resource "azurerm_role_assignment" "finance" {
scope = "/subscriptions/${module.subscription.subscription_id}"
role_definition_name = "Cost Management Reader"
principal_id = var.finance_team_ad_group_id
}
7. Budget alerts
resource "azurerm_consumption_budget_subscription" "main" {
name = "budget-${var.team_name}-${var.environment}"
subscription_id = module.subscription.subscription_id
amount = var.monthly_budget
time_grain = "Monthly"
time_period {
start_date = formatdate("YYYY-MM-01'T'00:00:00'Z'", timestamp())
}
notification {
enabled = true
threshold = 80
operator = "GreaterThan"
threshold_type = "Actual"
contact_emails = [var.team_email, "finance@example.com"]
}
notification {
enabled = true
threshold = 100
operator = "GreaterThan"
threshold_type = "Forecasted"
contact_emails = [var.team_email, "finance@example.com", "leadership@example.com"]
}
}
Practices I stick to
1. Treat subscriptions as cattle, not pets
I automate subscription creation and deletion. I never manually configure subscriptions in the pipeline path. The moment a human starts clicking through the portal for each one, you've broken the contract.
2. Implement approval workflows
# GitHub environment with approvals
environment: subscription-vending
protection_rules:
- type: required_reviewers
reviewers:
- platform-team
- finance-team
3. Use a service catalog
I give teams a menu of pre-defined subscription types rather than free-form inputs. This makes validation tractable and keeps the pipeline simple:
- Sandbox ($500/mo budget)
- Development ($2K/mo)
- Production ($10K/mo)
4. Automate decommissioning
# Auto-delete sandbox subscriptions after 90 days
resource "azurerm_policy_definition" "auto_delete_sandbox" {
name = "auto-delete-sandbox-subs"
policy_type = "Custom"
mode = "All"
display_name = "Auto-delete Sandbox Subscriptions"
policy_rule = jsonencode({
if = {
allOf = [
{
field = "type"
equals = "Microsoft.Subscription"
},
{
field = "tags['Environment']"
equals = "Sandbox"
},
{
field = "tags['CreatedDate']"
less = "[addDays(utcNow(), -90)]"
}
]
}
then = {
effect = "delete"
}
})
}
5. Maintain a subscription inventory
# Export to Azure Table Storage
resource "azurerm_storage_table" "subscriptions" {
name = "subscriptioninventory"
storage_account_name = azurerm_storage_account.platform.name
}
# Track: Team, Environment, Cost Center, Created Date, Budget
Common pitfalls
1. No approval process
Issue: Teams create unlimited subscriptions
Fix: Require manager + platform team approval
2. No budget controls
Issue: Runaway costs
Fix: Enforce budgets, alert at 80%, auto-shutdown at 100%
3. Inconsistent tagging
Issue: Can't track costs by team
Fix: Enforce tags via Azure Policy (deny creation without tags)
4. Manual networking
Issue: IP address conflicts
Fix: Automate VNet creation with IPAM (IP Address Management)
5. No subscription lifecycle
Issue: Zombie subscriptions cost money
Fix: Implement auto-deletion for sandbox, quarterly reviews for production
Monitoring and governance
Metrics I track
-- KQL query: Subscriptions created per month
Resources
| where type == "microsoft.subscription/aliases"
| extend CreatedDate = todatetime(tags["CreatedDate"])
| summarize Count=count() by bin(CreatedDate, 30d)
| render timechart
Compliance checks
# Run compliance scan
az policy state trigger-scan --resource-group rg-platform-governance
# Export non-compliant resources
az policy state list \
--filter "complianceState eq 'NonCompliant'" \
--query "[].{Resource:resourceId, Policy:policyDefinitionName}" \
--output table