Bicep Tips and Tricks | #8 | Agnostic Templates Through Config Files
Overview
Building on our previous exploration of Typed Variables, today we’re diving into one of my favorite patterns for creating maintainable and reusable Bicep templates: the Shared Variable File Pattern. This approach transforms your templates from being tightly coupled to specific configurations into truly agnostic, environment-ready solutions.
The beauty of this pattern lies in its simplicity - by extracting configuration data into external JSON or YAML files, you can create templates that adapt without modification. When combined with typed variables, this approach becomes even more powerful, providing compile-time validation and enhanced developer experience.
Why Use Config Files?
Before diving into the implementation, let’s understand why this pattern is so valuable:
- Separation of Concerns: Keep your infrastructure logic separate from configuration data
- Reduced Template Complexity: Remove large, complex variable definitions from your templates for better readability
- Reusability: Share common configurations across multiple templates without duplication
- Team Collaboration: Non-technical team members can modify configurations without touching Bicep code
The Pattern in Action
Traditional Approach (What We Want to Avoid)
Traditionally, you might embed all configuration directly in your Bicep template:
var nsgRules = [
{
name: 'AllowManagementEndpoint'
properties: {
description: 'Management endpoint for Azure portal and PowerShell'
sourceAddressPrefix: 'ApiManagement'
sourcePortRange: '*'
destinationAddressPrefix: 'VirtualNetwork'
destinationPortRange: '3443'
protocol: 'Tcp'
access: 'Allow'
priority: 100
direction: 'Inbound'
}
}
{
name: 'AllowAzureInfrastructureLoadBalancer'
properties: {
description: 'Azure Infrastructure Load Balancer'
sourceAddressPrefix: 'AzureLoadBalancer'
sourcePortRange: '*'
destinationAddressPrefix: 'VirtualNetwork'
destinationPortRange: '6390'
protocol: 'Tcp'
access: 'Allow'
priority: 110
direction: 'Inbound'
}
}
// ... many more rules
]
This approach has several downsides:
- Templates become bloated and hard to read
- Changes require modifying Bicep code
- No separation between infrastructure logic and configuration data
Modern Approach with Config Files
Let’s transform this using the shared variable file pattern combined with typed variables.
Step 1: Create Your Config File
Create the configuration file for your deployment:
configs/nsg-rules.json
{
"securityRules": [
{
"name": "AllowManagementEndpoint",
"properties": {
"description": "Management endpoint for Azure portal and PowerShell",
"sourceAddressPrefix": "ApiManagement",
"sourcePortRange": "*",
"destinationAddressPrefix": "VirtualNetwork",
"destinationPortRange": "3443",
"protocol": "Tcp",
"access": "Allow",
"priority": 100,
"direction": "Inbound"
}
},
{
"name": "AllowDeveloperAccess",
"properties": {
"description": "Allow developer access from corporate network",
"sourceAddressPrefix": "10.0.0.0/8",
"sourcePortRange": "*",
"destinationAddressPrefix": "VirtualNetwork",
"destinationPortRange": "443",
"protocol": "Tcp",
"access": "Allow",
"priority": 200,
"direction": "Inbound"
}
}
]
}
Step 2: Define Typed Variables
Create a user-defined type to ensure your config files match the expected structure:
// User-Defined Types
// ******************
@sealed()
@description('Defines the structure for NSG security rule properties.')
type nsgSecurityRuleProperties = {
@description('Description of the security rule.')
description: string
@description('Source address prefix or tag.')
sourceAddressPrefix: string
@description('Source port range.')
sourcePortRange: string
@description('Destination address prefix or tag.')
destinationAddressPrefix: string
@description('Destination port range.')
destinationPortRange: string
@description('Network protocol (Tcp, Udp, or *).')
protocol: 'Tcp' | 'Udp' | '*'
@description('Access type (Allow or Deny).')
access: 'Allow' | 'Deny'
@description('Priority value (100-4096).')
priority: int
@description('Direction (Inbound or Outbound).')
direction: 'Inbound' | 'Outbound'
}
@sealed()
@description('Defines the structure for NSG security rule.')
type nsgSecurityRule = {
@description('Name of the security rule.')
name: string
@description('Properties of the security rule.')
properties: nsgSecurityRuleProperties
}
@sealed()
@description('Defines the structure for NSG configuration file.')
type nsgConfig = {
@description('Array of security rules.')
securityRules: nsgSecurityRule[]
}
Step 3: Load and Use Configuration
Now your Bicep template becomes clean and agnostic:
// Parameters
// **********
@description('Environment to deploy to.')
param environment string
// Variables
// *********
// Load configuration with full type safety
var nsgConf nsgConfig = loadJsonContent('./configs/nsg-rules.json')
// Resources
// *********
resource networkSecurityGroup 'Microsoft.Network/networkSecurityGroups@2024-01-01' = {
name: 'nsg-apim'
location: resourceGroup().location
properties: {
securityRules: nsgConfig.securityRules
}
}
Advanced Techniques
Multi-Component Configuration Files
You can organise multiple configuration aspects in a single file and load only what you need:
configs/apim-config.json
{
"networking": {
"securityRules": [...],
"subnets": [...]
},
"apim": {
"sku": {
"name": "Developer",
"capacity": 1
},
"policies": [...]
},
"monitoring": {
"logAnalytics": {...},
"alerts": [...]
}
}
Load specific sections using JSONPath:
// Load only the networking configuration
var networkingConfig = loadJsonContent('./configs/apim-config.json', 'networking')
// Load only the APIM configuration
var apimConfig = loadJsonContent('./configs/apim-config.json', 'apim')
Best Practices
- Use Typed Variables: Define user-defined types for your configuration structures - this provides compile-time validation and excellent IntelliSense support
- Logical File Organisation: Create separate config files for different aspects (networking, security, monitoring) rather than one monolithic file
- Validate Early: Let typed variables catch configuration mismatches during authoring, not deployment
- Size Considerations: Remember that loaded content is included in the generated ARM template (4MB limit)
- Version Control: Keep config files in source control alongside your Bicep templates
Real-World Benefits
This pattern has transformed how I approach Bicep development:
- Faster Development: IntelliSense support with typed variables makes configuration editing a breeze
- Fewer Deployment Failures: Compile-time validation catches configuration errors before deployment
- Better Team Collaboration: Operations teams can modify configs without touching infrastructure code
- Easier Maintenance: Changes to configuration don’t require Bicep code modifications
Summary
The shared variable file pattern, enhanced with typed variables, creates a powerful combination for building maintainable, agnostic Bicep templates. By separating configuration from infrastructure logic, you gain flexibility, reusability, and compile-time safety that makes your Infrastructure as Code truly robust. Happy Bicep-ing!
For the original version of this post see Andrew Wilson's personal blog at Bicep Tips and Tricks | #8 | Agnostic Templates Through Config Files