Introduction: One Template, Four Environments #
You’ve written your Azure infrastructure templates. They work perfectly in development. Then you deploy to production using the same templates, and everything blows up.
Why? Because you hardcoded subscription IDs. Or IP ranges overlap. Or you accidentally deployed dev resources into production subscriptions. Or production locked down network security while dev needs wide-open access for testing.
The problem isn’t the templates—it’s the lack of proper environment separation.
After managing Azure infrastructure across 4 environments (dev, prod, drdev, drprod) with hub-and-spoke architecture spanning multiple subscriptions, I’ve learned that environment separation isn’t just about parameter files—it’s about architecting deployment scopes that enforce isolation at every level.
This post explains the deployment scope strategy that makes it impossible to accidentally deploy dev resources into production, overlap IP ranges, or violate security boundaries between environments.
The Problem: Shared Templates, Isolated Environments #
The Challenge #
You need to deploy the same infrastructure pattern across multiple environments:
- Development: Rapid iteration, cost optimization, relaxed security
- QA/Staging: Production-like testing with isolated data
- Production: High availability, security hardening, compliance
- Disaster Recovery: Separate region, separate subscriptions, production-grade resilience
Requirements:
- Same templates across all environments (DRY principle)
- Complete isolation between environments (no cross-contamination)
- Different configurations per environment (IP ranges, SKUs, feature flags)
- Safe deployments (impossible to accidentally deploy to wrong environment)
Traditional approach: Copy-paste templates for each environment, leading to drift and maintenance nightmares.
Better approach: Single source of truth with environment-specific deployment scopes.
The Solution: Deployment Scope Architecture #
Subscription-Scoped Deployments #
Every infrastructure definition uses subscription scope:
// definitions/hub.bicep
targetScope = 'subscription'
// Deploy resources across subscription
module network_rg 'br/public:avm/res/resources/resource-group:0.4.0' = {
name: '${network_rg_config.name}-${uniqueString(deployment().name)}'
scope: subscription(network_rg_config.targetSubscriptionId)
params: {
name: network_rg_config.name
location: location
tags: network_rg_config.tags
}
}
Why subscription scope?
- Cross-subscription deployments: Hub in one subscription, spokes in others
- Environment isolation: Each environment targets different subscription IDs
- Resource organization: Create resource groups as part of deployment
- Governance: Subscription-level RBAC prevents cross-environment access
The Deployment Scopes Pattern #
Environment Configuration Object #
The heart of the strategy is the deployment_scopes configuration object in parameter files:
// parameters/hub.bicepparam
var deployment_scopes = {
dev: {
hubSubscriptionId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' // Azure-Dev-Hub
vhub_addressSpace: '10.90.0.0/24'
ss_vnet_prefix: '10.91'
p2s_addressSpace: '172.16.172.0/22'
existingVwanResourceId: ''
existingFwPolicyResourceId: ''
}
prod: {
hubSubscriptionId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' // Azure-Prod
vhub_addressSpace: '10.10.0.0/24'
ss_vnet_prefix: '10.10'
p2s_addressSpace: '172.16.172.0/22'
existingVwanResourceId: ''
existingFwPolicyResourceId: ''
}
drdev: {
hubSubscriptionId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' // Azure-DR-Dev-Hub
vhub_addressSpace: '10.190.0.0/24'
ss_vnet_prefix: '10.191'
p2s_addressSpace: '172.16.180.0/22'
existingVwanResourceId: '/subscriptions/.../azure-vwan-dev-wus'
existingFwPolicyResourceId: ''
}
drprod: {
hubSubscriptionId: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb' // Azure-DR-Prod
vhub_addressSpace: '10.110.0.0/24'
ss_vnet_prefix: '10.110'
p2s_addressSpace: '172.16.180.0/22'
existingVwanResourceId: '/subscriptions/.../azure-vwan-prod-wus'
existingFwPolicyResourceId: ''
}
}
// Select current environment based on runtime variable
var iac_env = readEnvironmentVariable('iac_env', 'dev')
var currentEnvironment = deployment_scopes[toLower(iac_env)]
// Extract environment-specific values
var hub_subscriptionId = currentEnvironment.hubSubscriptionId
var vhub_addressSpace = currentEnvironment.vhub_addressSpace
var ss_vnet_prefix = currentEnvironment.ss_vnet_prefix
var p2s_addressSpace = currentEnvironment.p2s_addressSpace
How It Works #
1. Runtime Environment Selection
Environment is determined by the iac_env environment variable:
# Local deployment
$env:iac_env = "dev"
az deployment sub create --location WestUS --template-file hub.bicep --parameters hub.bicepparam
# Pipeline deployment (Azure DevOps)
env:
iac_env: $(iac_env) # Set in pipeline variables
2. Scope Lookup
The parameter file looks up the environment configuration:
var currentEnvironment = deployment_scopes[toLower(iac_env)]
If iac_env = "prod", then currentEnvironment becomes:
{
hubSubscriptionId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
vhub_addressSpace: '10.10.0.0/24'
// ...
}
3. Scope Application
Every module deployment uses the environment-specific subscription ID:
module network_rg 'br/public:avm/res/resources/resource-group:0.4.0' = {
scope: subscription(hub_subscriptionId) // ← Environment-specific subscription
params: {
name: '${scope_prefix}-network-rg-${toLower(iac_env)}-${location}'
// ...
}
}
Benefits of Deployment Scope Strategy #
1. Impossible to Deploy to Wrong Environment #
Scenario: Developer accidentally runs prod deployment locally.
Protection:
$env:iac_env = "prod"
az deployment sub create --location WestUS --template-file hub.bicep --parameters hub.bicepparam
Result: Deployment targets hubSubscriptionId: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa'
If the developer doesn’t have access to the production subscription, the deployment fails immediately with permission denied. The templates themselves enforce the isolation.
Without this pattern: Developer could accidentally deploy to production subscription if they have credentials.
2. Guaranteed IP Range Isolation #
Problem: Overlapping IP ranges break hub-and-spoke networking.
Solution: Environment-specific address spaces:
| Environment | Virtual Hub | Shared Services VNet | P2S VPN | Region |
|---|---|---|---|---|
| dev | 10.90.0.0/24 | 10.91.x.x/20 | 172.16.172.0/22 | WestUS |
| prod | 10.10.0.0/24 | 10.10.x.x/20 | 172.16.172.0/22 | WestUS |
| drdev | 10.190.0.0/24 | 10.191.x.x/20 | 172.16.180.0/22 | EastUS |
| drprod | 10.110.0.0/24 | 10.110.x.x/20 | 172.16.180.0/22 | EastUS |
These ranges are computed from the environment scope, not manually maintained in multiple places:
param vhub_config = {
name: '${scope_prefix}-vhub-${toLower(iac_env)}-${location}'
addressPrefix: vhub_addressSpace // ← From deployment_scopes lookup
// ...
}
Result: IP ranges cannot overlap because they’re centrally defined in the deployment_scopes object.
3. Environment-Specific Feature Flags #
Different environments need different features:
Dev:
- Basic SKUs (cost optimization)
- Relaxed firewall rules (developer access)
- No resource locks (frequent teardown/rebuild)
- Simplified topology
Production:
- Premium SKUs (performance, SLA)
- Strict firewall rules (compliance)
- CanNotDelete locks (prevent accidental deletion)
- Full topology with ExpressRoute, redundant gateways
Implementation:
// Conditional resource deployment based on environment
resource expressRouteGateway 'Microsoft.Network/expressRouteGateways@2024-01-01' = if (iac_env == 'prod' || iac_env == 'drprod') {
name: '${scope_prefix}-er-gateway-${toLower(iac_env)}-${location}'
location: location
properties: {
virtualHub: {
id: vhub.outputs.resourceId
}
}
}
// Environment-specific SKUs
var firewallSku = iac_env == 'prod' ? 'Premium' : 'Standard'
// Environment-specific locks
var resourceLock = (iac_env == 'prod' || iac_env == 'drprod') ? {
name: 'CanNotDelete'
kind: 'CanNotDelete'
} : null
4. Pipeline Integration with Branch Protection #
Azure DevOps pipeline configuration enforces environment separation:
# pipelines/deploy-dr.yml
variables:
- ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/main') }}:
- group: azure-iac-prod
- name: iac_env
value: drprod
- ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/dev') }}:
- group: azure-iac-dev
- name: iac_env
value: drdev
Branch-based environment mapping:
| Git Branch | Environment | Subscription | Approval Required |
|---|---|---|---|
dev | drdev | Azure-Dev-Hub | No |
main | drprod | Azure-DR-Prod | Yes (environment gate) |
Protection mechanisms:
- Dev branch cannot deploy to production (branch condition)
- Main branch requires manual approval (environment gate)
- Different variable groups per environment (credentials isolated)
- Separate deployment stages enforce ordering
5. Multi-Subscription Architecture #
The deployment scope pattern enables complex subscription topologies:
Production Environment:
├── Azure-Prod (Hub subscription)
│ ├── Virtual WAN
│ ├── Virtual Hub
│ ├── Azure Firewall
│ ├── VPN Gateways
│ └── Shared Services VNet
│
├── Azure-Prod-Spokes (Spoke subscription)
│ ├── Workload-A Spoke Network
│ ├── Workload-B Spoke Network
│ ├── Workload-C Spoke Network
│ └── Workload-D Spoke Network
│
├── Workload-A-Prod-Sub (Workload A subscription)
│ └── Workload A application resources
│
├── Workload-B-Prod-Sub (Workload B subscription)
│ └── Workload B application resources
│
└── Workload-C-Prod-Sub (Workload C subscription)
└── Workload C application resources
Each spoke references its own subscription:
// parameters/workload-a.bicepparam
var deployment_scopes = {
dev: {
networkSubscriptionId: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' // Azure-Dev-Spokes
workloadSubscriptionId: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' // Same for dev
}
prod: {
networkSubscriptionId: 'cccccccc-cccc-cccc-cccc-cccccccccccc' // Azure-Prod-Spokes
workloadSubscriptionId: 'dddddddd-dddd-dddd-dddd-dddddddddddd' // Workload-A-Prod-Sub
}
}
Benefits:
- Billing isolation: Each workload has separate Azure billing
- Security isolation: Separate RBAC, policies, security boundaries
- Blast radius containment: Issues in one workload don’t affect others
- Compliance: Separate subscriptions for regulated workloads
Implementation Patterns #
Pattern 1: Shared Parameter File, Environment Variable Selection #
Single parameter file serves all environments.
Advantages:
- Single source of truth
- Environment-specific values centralized in one place
- Easier to compare environments (all in one file)
- Fewer files to maintain
Trade-offs:
- Parameter file can become large (2,400+ lines for complex hub)
- All environments visible in one file (could be a pro or con)
Pattern 2: Resource Naming with Environment Suffix #
All resources include environment identifier:
var resourceName = '${scope_prefix}-${resource_type}-${toLower(iac_env)}-${location}'
// Examples:
// azure-vhub-dev-wus
// azure-vhub-prod-wus
// azure-vhub-drdev-eus
// azure-vhub-drprod-eus
Benefits:
- Resources self-document their environment
- Easy to identify environment in Azure Portal
- Prevents naming conflicts across environments
- Enables resource group regex matching for cleanup scripts
Pattern 3: Existing Resource References for DR #
Disaster recovery environments reference primary region resources:
drdev: {
existingVwanResourceId: '/subscriptions/.../azure-vwan-dev-wus'
existingFwPolicyResourceId: ''
}
drprod: {
existingVwanResourceId: '/subscriptions/.../azure-vwan-prod-wus'
existingFwPolicyResourceId: ''
}
Why?
- Virtual WAN spans regions (one vWAN, multiple regional hubs)
- Firewall policies can be shared across regions
- DR environments extend primary, don’t duplicate everything
Dev/Prod environments:
dev: {
existingVwanResourceId: '' // Create new
existingFwPolicyResourceId: '' // Create new
}
Pattern 4: Conditional Deployment Based on Region and Branch #
Some resources only deploy in primary region or specific branches:
// Domain controllers only in primary region (WestUS)
resource domainController1 'Microsoft.Compute/virtualMachines@2024-03-01' = if (region == 'WestUS') {
name: 'dc1-${toLower(iac_env)}'
// ...
}
// ExpressRoute only in production
resource expressRouteGateway '...' = if (source_branch == 'prod' || source_branch == 'drprod') {
// ...
}
// Locks only in production branches
var resourceLock = (source_branch == 'prod' || source_branch == 'drprod') ? {
kind: 'CanNotDelete'
} : null
Real-World Example: Hub Deployment #
Local Deployment (Development) #
# Set environment
$env:iac_env = "dev"
$env:resource_env = "dev"
$env:location = "WestUS"
$env:location_suffix = "wus"
$env:DC1_ADMIN_PASSWORD = "SecureP@ssw0rd123!"
$env:DC2_ADMIN_PASSWORD = "SecureP@ssw0rd456!"
# Deploy
az deployment sub create `
--location WestUS `
--template-file definitions/hub.bicep `
--parameters parameters/hub.bicepparam
What happens:
- Parameter file reads
iac_env = "dev"from environment variable - Looks up
deployment_scopes['dev']:hubSubscriptionId: 'xxxxxxxx-...'(Azure-Dev-Hub)vhub_addressSpace: '10.90.0.0/24'ss_vnet_prefix: '10.91'
- Deploys to dev subscription with dev IP ranges
- Creates resources named
azure-*-dev-wus - No resource locks applied (dev environment)
- Basic SKUs for cost optimization
Pipeline Deployment (Production) #
# pipelines/deploy-dr.yml (main branch)
variables:
- name: iac_env
value: drprod
- name: location
value: EastUS
stages:
- stage: Deploy_Prod
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
jobs:
- deployment: Deploy_hub_Prod
environment: prod # ← Requires manual approval
steps:
- task: AzureCLI@2
inputs:
azureSubscription: 'azure-iac-secret'
inlineScript: |
az deployment sub create \
--name "iac-drprod-$(Build.BuildId)" \
--location "EastUS" \
--template-file "./definitions/hub.bicep" \
--parameters "./parameters/hub.bicepparam"
env:
iac_env: drprod
location: EastUS
location_suffix: eus
What happens:
- Pipeline requires manual approval (environment: prod)
- Uses
azure-iac-prodvariable group (production credentials) - Parameter file reads
iac_env = "drprod" - Looks up
deployment_scopes['drprod']:hubSubscriptionId: 'bbbbbbbb-...'(Azure-DR-Prod)vhub_addressSpace: '10.110.0.0/24'- References existing vWAN from primary region
- Deploys to DR prod subscription with DR IP ranges
- Creates resources named
azure-*-drprod-eus - Applies CanNotDelete locks (production)
- Premium SKUs for production SLA
Impossible to deploy to wrong environment:
- Dev branch can’t trigger prod deployment (branch condition)
- Prod requires approval gate (manual verification)
- Different service connections per environment (credential isolation)
- Subscription IDs hardcoded in deployment_scopes (fail if wrong sub)
Why This Approach Follows Best Practices #
1. Infrastructure as Code Best Practices #
✅ Single source of truth: One template serves all environments ✅ DRY principle: No duplicated templates ✅ Declarative: Desired state, not imperative scripts ✅ Version controlled: All configuration in Git ✅ Testable: Can validate against all environments
2. Azure Well-Architected Framework #
✅ Reliability: Environment isolation prevents cascading failures ✅ Security: Subscription boundaries enforce RBAC isolation ✅ Cost Optimization: Environment-specific SKUs optimize spend ✅ Operational Excellence: Consistent deployment patterns ✅ Performance Efficiency: Premium SKUs where needed, basic elsewhere
3. Cloud Adoption Framework #
✅ Environment strategy: Clear dev/test/prod separation ✅ Subscription design: Purpose-driven subscription boundaries ✅ Resource organization: Consistent naming, tagging ✅ Governance: Policy, RBAC, locks at subscription level ✅ Platform automation: IaC with environment-specific deployment
4. DevOps Best Practices #
✅ Shift left: Validate environments early in pipeline ✅ Fail fast: Deployment fails immediately if wrong subscription ✅ Environment parity: Same templates guarantee consistency ✅ Approval gates: Production deployments require manual approval ✅ Traceability: Git commit → build ID → deployment name
Common Pitfalls and How This Pattern Avoids Them #
Pitfall 1: Hardcoded Values #
❌ Anti-pattern:
param subscriptionId string = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' // Hardcoded
✅ This pattern:
var subscriptionId = deployment_scopes[iac_env].hubSubscriptionId // Environment-aware
Pitfall 2: Copy-Paste Templates #
❌ Anti-pattern:
templates/
├── hub-dev.bicep
├── hub-prod.bicep
└── hub-dr.bicep # Drift over time
✅ This pattern:
definitions/
└── hub.bicep # Single source of truth
Pitfall 3: Manual IP Range Management #
❌ Anti-pattern:
// hub-dev.bicep
param vnetAddressSpace string = '10.90.0.0/24'
// hub-prod.bicep
param vnetAddressSpace string = '10.10.0.0/24' // Manually kept in sync
✅ This pattern:
var deployment_scopes = {
dev: { vnet_prefix: '10.90' }
prod: { vnet_prefix: '10.10' }
}
var vnetAddressSpace = '${currentEnvironment.vnet_prefix}.0.0/24'
Pitfall 4: Inconsistent Naming #
❌ Anti-pattern:
azure-vhub-dev
azure-hub-prod // Missing "v", inconsistent
azure-drprod-vhub // Order wrong
✅ This pattern:
var name = '${scope_prefix}-${resource_type}-${toLower(iac_env)}-${location}'
// Always: azure-vhub-{env}-{loc}
Pitfall 5: No Deployment Safeguards #
❌ Anti-pattern:
az deployment sub create --template-file hub-prod.bicep
# Could accidentally run in wrong subscription
✅ This pattern:
scope: subscription(hub_subscriptionId) // Explicit subscription targeting
# Fails immediately if don't have access to target subscription
Extending the Pattern #
Adding a New Environment #
Scenario: Need to add a QA environment in Central US.
Steps:
- Update deployment_scopes in parameter file:
var deployment_scopes = {
// ... existing environments
qa: {
hubSubscriptionId: 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee'
vhub_addressSpace: '10.200.0.0/24' // New IP range
ss_vnet_prefix: '10.201'
p2s_addressSpace: '172.16.200.0/22'
existingVwanResourceId: ''
existingFwPolicyResourceId: ''
}
}
- Update pipeline (if needed):
variables:
- ${{ if eq(variables['Build.SourceBranch'], 'refs/heads/qa') }}:
- name: iac_env
value: qa
- name: location
value: CentralUS
- Deploy:
$env:iac_env = "qa"
$env:location = "CentralUS"
az deployment sub create --location CentralUS --template-file hub.bicep --parameters hub.bicepparam
Result: New environment deployed with no template changes. IP ranges guaranteed not to conflict.
Adding Environment-Specific Configuration #
Scenario: Production needs Azure DDoS Protection, dev doesn’t.
Implementation:
var deployment_scopes = {
dev: {
enableDDoSProtection: false
ddosProtectionPlanId: ''
}
prod: {
enableDDoSProtection: true
ddosProtectionPlanId: '/subscriptions/.../providers/Microsoft.Network/ddosProtectionPlans/prod-ddos'
}
}
resource vnet 'Microsoft.Network/virtualNetworks@2024-01-01' = {
properties: {
enableDdosProtection: currentEnvironment.enableDDoSProtection
ddosProtectionPlan: currentEnvironment.enableDDoSProtection ? {
id: currentEnvironment.ddosProtectionPlanId
} : null
}
}
Conclusion: Deployment Scopes as Architectural Foundation #
The deployment scope strategy isn’t just a templating technique—it’s an architectural pattern that enforces environment isolation at the infrastructure layer.
Key Principles:
- Environment selection at runtime, not compile time
- Subscription boundaries as security boundaries
- Centralized configuration in deployment_scopes object
- Computed resource properties from environment lookup
- Pipeline enforcement with branch protection and approval gates
What makes this production-grade:
✅ Cannot accidentally deploy to wrong environment (subscription check fails) ✅ Cannot overlap IP ranges (centrally defined per environment) ✅ Cannot skip production approval (environment gate required) ✅ Cannot use wrong credentials (separate variable groups) ✅ Cannot create naming conflicts (environment suffix in all names)
The difference between hobbyist and enterprise IaC:
- Hobbyist: Copy templates, manually update values, hope for the best
- Enterprise: Single template, environment-specific scopes, impossible to make mistakes
When someone asks “How do you manage multiple environments in Azure?”, the answer isn’t parameter files or variable substitution—it’s deployment scope architecture that makes wrong deployments structurally impossible.
If your organization is struggling with environment drift, accidental production deployments, or IP range conflicts, this pattern provides a foundation for safe, scalable, multi-environment infrastructure as code.
Technologies: Azure Bicep, Azure Resource Manager, Azure DevOps, Subscription-scoped deployments
Skills Demonstrated: Multi-environment architecture, Subscription management, Environment isolation, Pipeline orchestration, IaC best practices
Related posts:
- Hub-and-Spoke Deployment Orchestration
- Parameterization in Infrastructure as Code
- Building Enterprise-Scale Azure Infrastructure
Part of a series on building enterprise-scale Azure infrastructure at scale.