Moving infrastructure management from the Azure Portal to code is one of those decisions that feels uncomfortable at first but pays dividends immediately. I’ve watched teams spend weeks chasing deployment inconsistencies, only to realize they were clicking through slightly different configuration paths each time. Bicep changed that for us, and I want to show you how to build the same reliable, repeatable deployments for multi-tier applications.
This guide covers the real patterns we use: writing maintainable Bicep templates, organizing code to avoid duplication, automating deployments with GitHub Actions, and managing dev, staging, and production environments from a single source of truth.
Why Bicep for Multi-Tier Applications
If you’ve written ARM templates, you know the JSON can be verbose and hard to reason about. Bicep is ARM templates simplified. It’s a domain-specific language that compiles to ARM, so you get all the power of Azure’s infrastructure engine without the syntax overhead.
For multi-tier applications, this matters because you’re typically deploying:
- A container registry to store images
- Multiple Container App environments (API tier, background jobs, etc.)
- Databases or storage backends
- Monitoring and logging infrastructure
- Environment-specific secrets and configuration
Doing this manually means dozens of Portal clicks per environment. With Bicep, it’s a few template files and one CLI command.
Structuring Your Bicep Templates
The key to maintainability is treating Bicep like regular code: modular, DRY, and easy to test. Here’s a structure that works:
infrastructure/
├── main.bicep # Orchestrates the deployment
├── parameters.json # Default parameter values
├── parameters.dev.json # Environment overrides
├── parameters.staging.json
├── parameters.prod.json
└── modules/
├── container-registry.bicep
├── container-apps-env.bicep
├── container-app.bicep
├── cosmos-db.bicep
└── monitoring.bicep
This structure lets you reuse modules across environments and keeps parameters separate from logic.
Building a Container Registry Module
Start simple. A container registry is a single resource, but wrapping it in a module makes it reusable and testable:
// modules/container-registry.bicep
param location string
param registryName string
param environment string
var tags = {
environment: environment
managedBy: 'bicep'
createdDate: utcNow('u')
}
resource containerRegistry 'Microsoft.ContainerRegistry/registries@2023-07-01' = {
name: registryName
location: location
tags: tags
sku: {
name: 'Standard'
}
properties: {
adminUserEnabled: false
publicNetworkAccess: 'Enabled'
}
}
output registryId string = containerRegistry.id
output loginServer string = containerRegistry.properties.loginServer
Notice the outputs. They let downstream modules reference this registry without hardcoding values. That’s how you avoid duplication.
Container Apps Environment and Apps
Container Apps require a managed environment. Create that first, then deploy individual apps into it:
// modules/container-apps-env.bicep
param location string
param environmentName string
param logAnalyticsWorkspaceId string
param environment string
var tags = {
environment: environment
managedBy: 'bicep'
}
resource containerAppsEnv 'Microsoft.App/managedEnvironments@2023-04-01-preview' = {
name: environmentName
location: location
tags: tags
properties: {
appLogsConfiguration: {
destination: 'log-analytics'
logAnalyticsConfiguration: {
customerId: reference(logAnalyticsWorkspaceId).customerId
sharedKey: listKeys(logAnalyticsWorkspaceId, '2021-06-01').primarySharedKey
}
}
}
}
output environmentId string = containerAppsEnv.id
output environmentName string = containerAppsEnv.name
Once the environment exists, deploy Container Apps that reference it:
// modules/container-app.bicep
param location string
param appName string
param containerImage string
param environmentId string
param cpu string = '0.5'
param memory string = '1.0Gi'
param minReplicas int = 1
param maxReplicas int = 3
param environment string
var tags = {
environment: environment
managedBy: 'bicep'
}
resource containerApp 'Microsoft.App/containerApps@2023-04-01-preview' = {
name: appName
location: location
tags: tags
properties: {
managedEnvironmentId: environmentId
configuration: {
ingress: {
external: true
targetPort: 8080
allowInsecure: false
}
registries: []
secrets: []
}
template: {
containers: [
{
name: appName
image: containerImage
resources: {
cpu: cpu
memory: memory
}
}
]
scale: {
minReplicas: minReplicas
maxReplicas: maxReplicas
}
}
}
}
output fqdn string = containerApp.properties.configuration.ingress.fqdn
output appId string = containerApp.id
This template is parameterized, so the same module works for your API tier, background jobs, or any other containerized workload. Just pass different values.
The Main Orchestration Template
Now tie it together. The main.bicep file orchestrates all modules and passes outputs between them:
// main.bicep
param location string = resourceGroup().location
param environment string
param projectName string
// Container Registry
module containerRegistry 'modules/container-registry.bicep' = {
name: 'containerRegistry'
params: {
location: location
registryName: '${projectName}acr${environment}'
environment: environment
}
}
// Log Analytics Workspace for monitoring
module monitoring 'modules/monitoring.bicep' = {
name: 'monitoring'
params: {
location: location
workspaceName: '${projectName}-law-${environment}'
environment: environment
}
}
// Container Apps Environment
module containerAppsEnv 'modules/container-apps-env.bicep' = {
name: 'containerAppsEnv'
params: {
location: location
environmentName: '${projectName}-cae-${environment}'
logAnalyticsWorkspaceId: monitoring.outputs.workspaceId
environment: environment
}
}
// API Container App
module apiApp 'modules/container-app.bicep' = {
name: 'apiApp'
params: {
location: location
appName: '${projectName}-api-${environment}'
containerImage: '${containerRegistry.outputs.loginServer}/api:latest'
environmentId: containerAppsEnv.outputs.environmentId
minReplicas: environment == 'prod' ? 2 : 1
maxReplicas: environment == 'prod' ? 5 : 2
environment: environment
}
}
// Background Jobs Container App
module jobsApp 'modules/container-app.bicep' = {
name: 'jobsApp'
params: {
location: location
appName: '${projectName}-jobs-${environment}'
containerImage: '${containerRegistry.outputs.loginServer}/jobs:latest'
environmentId: containerAppsEnv.outputs.environmentId
minReplicas: 1
maxReplicas: environment == 'prod' ? 3 : 1
environment: environment
}
}
output registryLoginServer string = containerRegistry.outputs.loginServer
output apiAppFqdn string = apiApp.outputs.fqdn
output jobsAppFqdn string = jobsApp.outputs.fqdn
Notice how we use the environment parameter to adjust scaling for production. This is how you avoid three separate templates while still respecting environment differences.
Environment-Specific Parameters
Create separate parameter files for each environment. Keep them minimal, overriding only what differs:
// parameters.dev.json
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"location": {
"value": "eastus"
},
"environment": {
"value": "dev"
},
"projectName": {
"value": "myapp"
}
}
}
// parameters.prod.json
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"location": {
"value": "eastus"
},
"environment": {
"value": "prod"
},
"projectName": {
"value": "myapp"
}
}
}
Both files are identical here because the template itself handles scaling and replication differences. That’s the goal: one template, multiple environments, no duplication.
Automating Deployments with GitHub Actions
Now for the automation. A GitHub Actions workflow validates your Bicep, builds and pushes container images, and deploys infrastructure:
name: Deploy to Azure
on:
push:
branches:
- main
- develop
paths:
- 'infrastructure/**'
- 'src/**'
- '.github/workflows/deploy.yml'
env:
REGISTRY: ${{ secrets.AZURE_REGISTRY_LOGIN_SERVER }}
PROJECT_NAME: myapp
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine environment
id: env
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "env_name=prod" >> $GITHUB_OUTPUT
echo "resource_group=rg-myapp-prod" >> $GITHUB_OUTPUT
else
echo "env_name=dev" >> $GITHUB_OUTPUT
echo "resource_group=rg-myapp-dev" >> $GITHUB_OUTPUT
fi
- name: Build container images
run: |
docker build -t ${{ env.REGISTRY }}/api:${{ github.sha }} ./src/api
docker build -t ${{ env.REGISTRY }}/jobs:${{ github.sha }} ./src/jobs
- name: Login to Azure Container Registry
uses: azure/docker-login@v1
with:
login-server: ${{ env.REGISTRY }}
username: ${{ secrets.AZURE_CLIENT_ID }}
password: ${{ secrets.AZURE_CLIENT_SECRET }}
- name: Push images
run: |
docker push ${{ env.REGISTRY }}/api:${{ github.sha }}
docker push ${{ env.REGISTRY }}/jobs:${{ github.sha }}
docker tag ${{ env.REGISTRY }}/api:${{ github.sha }} ${{ env.REGISTRY }}/api:latest
docker tag ${{ env.REGISTRY }}/jobs:${{ github.sha }} ${{ env.REGISTRY }}/jobs:latest
docker push ${{ env.REGISTRY }}/api:latest
docker push ${{ env.REGISTRY }}/jobs:latest
- name: Login to Azure
uses: azure/login@v1
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Validate Bicep
run: |
az bicep build --file infrastructure/main.bicep
- name: Deploy infrastructure
run: |
az deployment group create \
--resource-group ${{ steps.env.outputs.resource_group }} \
--template-file infrastructure/main.bicep \
--parameters infrastructure/parameters.${{ steps.env.outputs.env_name }}.json
- name: Logout from Azure
run: az logout
This workflow does several things: it detects which branch triggered the push, sets the environment accordingly, builds and pushes container images, validates your Bicep syntax, and deploys everything to the right resource group. All automatically.
Managing Secrets and Configuration
Don’t hardcode secrets in templates. Use Azure Key Vault and reference it from your Bicep:
// In main.bicep
param keyVaultName string
param keyVaultResourceGroup string
resource keyVault 'Microsoft.KeyVault/vaults@2023-02-01' existing = {
name: keyVaultName
scope: resourceGroup(keyVaultResourceGroup)
}
module containerApp 'modules/container-app.bicep' = {
name: 'apiApp'
params: {
location: location
appName: '${projectName}-api-${environment}'
containerImage: '${containerRegistry.outputs.loginServer}/api:latest'
environmentId: containerAppsEnv.outputs.environmentId
dbConnectionString: keyVault.getSecret('db-connection-string')
environment: environment
}
}
The Key Vault must exist before the deployment, but once it does, Bicep retrieves secrets at deployment time. This keeps them out of your repository and parameter files.
Testing and Validation
Before deploying to production, validate your templates locally:
// Validate Bicep syntax
az bicep build --file infrastructure/main.bicep
// Validate template against subscription constraints
az deployment group validate \
--resource-group rg-myapp-dev \
--template-file infrastructure/main.bicep \
--parameters infrastructure/parameters.dev.json
// Do a what-if to see what will change
az deployment group what-if \
--resource-group rg-myapp-dev \
--template-file infrastructure/main.bicep \
--parameters infrastructure/parameters.dev.json
The what-if command is particularly valuable. It shows exactly what will be created, updated, or deleted before you commit to the change. Use it every time.
Scaling and Auto-Scaling
Container Apps handle scaling through the scale rules in your template. Set minimum and maximum replicas, and Container Apps automatically scales between them based on CPU and memory usage:
template: {
scale: {
minReplicas: 2
maxReplicas: 10
rules: [
{
name: 'cpu'
custom: {
type: 'cpu'
metadata: {
type: 'Utilization'
value: '70'
}
}
}
]
}
}
This scales up when CPU exceeds 70 percent and scales down when it drops. Adjust thresholds based on your workload characteristics.
Monitoring and Observability
Your monitoring module should create a Log Analytics workspace and Application Insights instance:
// modules/monitoring.bicep
resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2021-06-01' = {
name: workspaceName
location: location
tags: tags
properties: {
sku: {
name: 'PerGB2018'
}
retentionInDays: environment == 'prod' ? 90 : 30
}
}
resource appInsights 'Microsoft.Insights/components@2020-02-02' = {
name: '${projectName}-ai-${environment}'
location: location
tags: tags
kind: 'web'
properties: {
Application_Type: 'web'
WorkspaceResourceId: logAnalyticsWorkspace.id
RetentionInDays: environment == 'prod' ? 90 : 30
}
}
output workspaceId string = logAnalyticsWorkspace.id
output appInsightsKey string = appInsights.properties.InstrumentationKey
Pass the Application Insights key to your Container Apps so they send telemetry. This gives you visibility into errors, performance, and user behavior without extra configuration.
Putting It All Together
Here’s the workflow in practice:
- You update your API code and push to GitHub
- GitHub Actions triggers automatically
- It builds new container images and pushes them to your registry
- It validates your Bicep templates
- It runs a what-if to show what will change
- It deploys the infrastructure and updates your Container Apps
- Your new code is live in minutes, with zero manual steps
The first time you set this up, it takes a few hours. After that, deployments are reliable, repeatable, and fast. More importantly, your infrastructure is version-controlled, reviewed in pull requests, and auditable.
Common Pitfalls and How to Avoid Them
A few things I’ve learned the hard way:
Naming conventions matter. Use consistent naming across resources so you can find them in the Portal or CLI. Include the environment in the name. It saves debugging time.
Don’t skip the what-if step. It’s tempting to deploy directly, especially in dev. Don’t. The what-if output often catches mistakes before they cause downtime.
Keep parameter files minimal. If you’re overriding more than a few values per environment, your template isn’t abstract enough. Refactor.
Version your container images. Using only latest makes it impossible to rollback. Tag images with commit SHAs or semantic versions.
Test in dev first. Always deploy to dev, validate it works, then promote to staging and production. Never deploy directly to production.
Next Steps
Start small. Create a single Container App with Bicep, deploy it via GitHub Actions, and get comfortable with the workflow. Once that’s solid, add more resources: databases, caches, additional apps. The patterns stay the same.
Bicep and GitHub Actions transform deployment from a manual, error-prone process into something reliable and auditable. Your future self, and your team, will thank you.
What’s the difference between Bicep and ARM templates?
Bicep is a domain-specific language that compiles to ARM templates. It’s simpler to read and write, with less boilerplate JSON. You get all the same capabilities as ARM templates without the syntax overhead. Behind the scenes, the Azure CLI converts your Bicep to ARM before deployment.
Can I use the same Bicep template for dev, staging, and production?
Yes, that’s the whole idea. Use a single template with environment-specific parameter files. The template uses conditionals (like minReplicas: environment == ‘prod’ ? 2 : 1) to adjust behavior per environment. This avoids duplication and makes it easier to keep environments in sync.
How do I handle secrets like database passwords in Bicep?
Don’t put secrets in Bicep files or parameter files. Store them in Azure Key Vault and reference them using the keyVault.getSecret() function inside your template. This keeps secrets out of your repository and audit logs.
What happens if a GitHub Actions deployment fails partway through?
Azure deployments are atomic at the resource group level. If a deployment fails, Azure automatically rolls back any changes made during that deployment. Your infrastructure stays in the state it was before the failed deployment started. This is why it’s safe to automate.
Can I use Bicep with other CI/CD systems besides GitHub Actions?
Absolutely. Bicep is just a templating language. Any CI/CD system that can run the Azure CLI (GitHub Actions, Azure Pipelines, GitLab CI, Jenkins, etc.) can deploy Bicep templates. The examples here use GitHub Actions, but the patterns work everywhere.