Building Multi-Region Azure Infrastructure with Bicep — Lessons from the Deep End

For the past few weeks, I’ve been digging into Bicep, Azure’s domain-specific language for infrastructure-as-code. I wanted to go beyond basic single-region deployments and challenge myself to build something more real-world: a reusable, modular, and multi-region infrastructure setup that could scale and adapt across environments.
This post is a breakdown of what I built, the decisions I made, and some of the issues I ran into along the way. Hopefully future-me — or anyone else doing something similar — finds it useful.
What I Built
At a high level, this project deploys:
- App Services in three Azure regions
- Azure SQL logical servers + databases, one per region
- Virtual Networks with frontend/backend subnets, per region
- Conditional resources based on environment (
dev
vsprod
) - Outputs that summarize everything once deployed
The structure is fully modular — each major component (App, DB, VNet) lives in its own Bicep module, and the main file orchestrates deployments across regions using loops.
Key Decisions & Learnings
1. Start Modular
I knew early on I didn’t want to write one giant .bicep
file, so I broke everything into separate modules:
-
app.bicep
: App Service + App Plan -
database.bicep
: SQL Server + SQL DB (+ auditing in prod) -
vnet.bicep
: Region-specific VNets with subnet definitions
This made debugging easier, but also gave me flexibility — I could iterate on one module at a time and test it in isolation.
2. Dynamic, Secure Parameters
No hardcoded secrets. I marked my SQL credentials as @secure()
parameters. I didn’t integrate Key Vault (yet), but the setup is ready for it.
Names like sqlServerName
and appName
use uniqueString(resourceGroup().id)
to ensure uniqueness across environments and reruns. That little pattern saved me a ton of headaches.
3. Multi-Region with Bicep Loops
Once I had a list of regions:
param locations array = [
'westus'
'eastus2'
'westus2'
]
…I used Bicep’s for
loop pattern to deploy everything in each one:
module appModule 'modules/app.bicep' = [for (location, i) in locations: {
name: 'app-${location}'
...
}]
This was one of the coolest parts. Each iteration gets a unique name, and I use the index (i
) to generate IP ranges and subnet prefixes per region.
4. Environment-Based Logic
I wanted different behaviors in dev
vs prod
:
-
prod
uses a higher App Service SKU (P1v2
) -
prod
turns on SQL auditing and deploys a Storage Account -
dev
skips that extra auditing config
This was done with simple conditional flags:
var auditingEnabled = environment == 'prod'
It’s a simple trick, but makes the modules way more flexible.
5. Location Consistency
One of the earliest bugs I ran into: SQL Servers and Databases have to be in the same region, or the deployment fails. I made sure every module uses the same location
parameter passed from the main file — defaulting to the resource group’s location unless explicitly overridden.
Outputs That Help You Test Fast
Each module returns outputs, and the root template summarizes them:
output appSummary array = [for (location, i) in locations: {
region: location
url: appModule[i].outputs.appServiceUrl
}]
I even added https://
directly in the App Service output so I could just click the link right after deploying and test immediately. Small thing, big productivity boost.
Things I Messed Up (and Fixed)
-
Hardcoded resource names → broke everything on the second run. Fixed by using
uniqueString
. - Forgot to loop VNets → initially deployed just one VNet, then added a loop to create one per region with indexed IP spaces.
-
Inconsistent locations → SQL resources failed until I ensured all related resources shared the same
location
. - Tried to over-optimize too early → once I relaxed and just focused on clean patterns, everything clicked into place faster.
What I Want to Add Next
- [ ] Private endpoints for SQL
- [ ] Key Vault integration
- [ ] GitHub Actions workflow for CI/CD
- [ ] CDN module for static asset distribution
- [ ] Cost estimation output (maybe?)
Closing Thoughts
This project gave me a much deeper appreciation of Bicep. It feels like the right balance of abstraction and control. It’s declarative, but you can still think programmatically when you need to — like looping, conditional logic, and dynamic outputs.
More than anything, it taught me how real-world infra needs to be flexible, modular, and repeatable — and how a little structure upfront saves a lot of time later.
If you’re experimenting with Bicep or looking to level up your Azure IaC skills, I’d definitely recommend trying a multi-region setup like this. It forces you to think about naming, location, outputs, and reusability in a really practical way.
If this was helpful or if you’ve got Bicep ideas to share, come say hey on LinkedIn. Let’s learn together!