CloudTrips Needs a Complete Repeatable Environment? Build It with Bicep

Published on:

CloudTrips can deploy a storage workload, but a usable application foundation also needs a secure place for secrets and a shared monitoring destination. Creating those resources manually would give DEV, TEST, and PROD different names, tags, settings, and deployment histories.

Build the complete foundation from one subscription-level Bicep entry point. For this learning path, “complete environment” means:

  • a resource group
  • a storage account and the environment-specific containers
  • a Key Vault that uses Azure RBAC
  • a Log Analytics workspace
  • a workspace-based Application Insights resource
  • consistent ownership, cost, environment, and management tags

Application compute and networking are outside this lab. They can be added as later modules without changing the structure established here.

This capstone builds on: Use Deployment Scopes and Use Azure Verified Modules. Complete those trips first because this lab extends their subscription entry point, parameter files, modular workload, and storage wrapper. The Deployment Stacks trip is a separate lifecycle lab and is not required.

Add the Key Vault Module

Create cloudtrips-bicep/modules/key-vault.bicep. The name is deterministic but globally unique because it combines the environment with a hash of the resource group ID:

param environment string
param location string
param tags object

var keyVaultName = 'kvct${toLower(environment)}${uniqueString(resourceGroup().id)}'

resource keyVault 'Microsoft.KeyVault/vaults@2026-02-01' = {
  name: keyVaultName
  location: location
  tags: tags
  properties: {
    accessPolicies: []
    enablePurgeProtection: true
    enableRbacAuthorization: true
    enableSoftDelete: true
    enabledForDeployment: false
    enabledForDiskEncryption: false
    enabledForTemplateDeployment: false
    networkAcls: {
      bypass: 'AzureServices'
      defaultAction: 'Allow'
      ipRules: []
      virtualNetworkRules: []
    }
    publicNetworkAccess: 'Enabled'
    sku: {
      family: 'A'
      name: 'standard'
    }
    softDeleteRetentionInDays: 7
    tenantId: tenant().tenantId
  }
}

output keyVaultName string = keyVault.name
output keyVaultId string = keyVault.id
output keyVaultUri string = keyVault.properties.vaultUri

The module creates the vault boundary but no secrets. Secret values must not be written into the repository. enableRbacAuthorization: true means access will be granted with Azure roles rather than legacy Key Vault access policies.

Purge protection is enabled and cannot later be disabled. Public network access keeps the learning deployment usable without creating a virtual network. A production design can also place the vault behind a private endpoint.

Add the Monitoring Module

Create cloudtrips-bicep/modules/monitoring.bicep. It deploys the Log Analytics workspace first and connects Application Insights to it:

param environment string
param location string
param tags object

var resourceSuffix = uniqueString(resourceGroup().id)
var logAnalyticsWorkspaceName = 'log-ct-${toLower(environment)}-${resourceSuffix}'
var applicationInsightsName = 'appi-ct-${toLower(environment)}-${resourceSuffix}'

resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2025-07-01' = {
  name: logAnalyticsWorkspaceName
  location: location
  tags: tags
  properties: {
    features: {
      disableLocalAuth: false
      enableLogAccessUsingOnlyResourcePermissions: true
    }
    publicNetworkAccessForIngestion: 'Enabled'
    publicNetworkAccessForQuery: 'Enabled'
    retentionInDays: 30
    sku: {
      name: 'PerGB2018'
    }
    workspaceCapping: {
      dailyQuotaGb: 1
    }
  }
}

resource applicationInsights 'Microsoft.Insights/components@2020-02-02' = {
  name: applicationInsightsName
  location: location
  tags: tags
  kind: 'web'
  properties: {
    Application_Type: 'web'
    DisableIpMasking: false
    DisableLocalAuth: false
    Flow_Type: 'Bluefield'
    IngestionMode: 'LogAnalytics'
    RetentionInDays: 90
    SamplingPercentage: 100
    WorkspaceResourceId: logAnalyticsWorkspace.id
    publicNetworkAccessForIngestion: 'Enabled'
    publicNetworkAccessForQuery: 'Enabled'
  }
}

output logAnalyticsWorkspaceName string = logAnalyticsWorkspace.name
output logAnalyticsWorkspaceId string = logAnalyticsWorkspace.id
output applicationInsightsName string = applicationInsights.name
output applicationInsightsId string = applicationInsights.id

WorkspaceResourceId: logAnalyticsWorkspace.id is a symbolic reference. Bicep uses it to infer that Application Insights must wait for the workspace.

The one-gigabyte daily workspace cap limits accidental learning-environment ingestion, but monitoring resources can still create Azure cost.

Turn the Entry Point into the Environment Orchestrator

Keep the existing parameters in cloudtrips-bicep/main.bicep. Add one shared tag object so every new module receives the same business metadata:

var commonTags = {
  Application: applicationName
  CostCenter: costCenter
  Environment: toUpper(environment)
  ManagedBy: 'Bicep'
  Owner: owner
}

Keep the existing storage module and add:

module keyVault './modules/key-vault.bicep' = {
  name: '${deployment().name}-key-vault'
  params: {
    environment: environment
    location: location
    tags: commonTags
  }
}

module monitoring './modules/monitoring.bicep' = {
  name: '${deployment().name}-monitoring'
  params: {
    environment: environment
    location: location
    tags: commonTags
  }
}

Expose the important module results and a compact deployment summary:

output keyVaultName string = keyVault.outputs.keyVaultName
output keyVaultId string = keyVault.outputs.keyVaultId
output keyVaultUri string = keyVault.outputs.keyVaultUri
output logAnalyticsWorkspaceName string = monitoring.outputs.logAnalyticsWorkspaceName
output logAnalyticsWorkspaceId string = monitoring.outputs.logAnalyticsWorkspaceId
output applicationInsightsName string = monitoring.outputs.applicationInsightsName
output applicationInsightsId string = monitoring.outputs.applicationInsightsId
output environmentSummary object = {
  applicationInsightsName: monitoring.outputs.applicationInsightsName
  keyVaultName: keyVault.outputs.keyVaultName
  logAnalyticsWorkspaceName: monitoring.outputs.logAnalyticsWorkspaceName
  resourceGroupName: resourceGroup().name
  storageAccountName: storage.outputs.storageAccountName
}

Pass those outputs through subscription.bicep. This remains the single command entry point that creates the resource group and then runs main.bicep inside it.

Complete the Environment Parameter Files

The existing resource-group parameter files still work because the input contract did not change. Add subscription-level files for DEV and PROD beside the existing TEST file:

  • environments/dev.subscription.bicepparam
  • environments/test.subscription.bicepparam
  • environments/prod.subscription.bicepparam

Each file points to subscription.bicep and supplies its own resource-group name, redundancy, cost center, container list, and audit-container decision. For example, PROD uses Standard_ZRS, three application containers, and the audit container, while DEV stays on Standard_LRS with only uploads.

Validate the Complete Solution

From the repository root, lint every entry point and new module:

az bicep lint \
  --file cloudtrips-bicep/modules/key-vault.bicep

az bicep lint \
  --file cloudtrips-bicep/modules/monitoring.bicep

az bicep lint \
  --file cloudtrips-bicep/main.bicep

az bicep lint \
  --file cloudtrips-bicep/subscription.bicep

Compile all three environment contracts:

az bicep build-params \
  --file cloudtrips-bicep/environments/dev.subscription.bicepparam \
  --stdout > /dev/null

az bicep build-params \
  --file cloudtrips-bicep/environments/test.subscription.bicepparam \
  --stdout > /dev/null

az bicep build-params \
  --file cloudtrips-bicep/environments/prod.subscription.bicepparam \
  --stdout > /dev/null

Continue when every command completes without diagnostics.

The approved TEST snapshot must now change because Key Vault and monitoring are intentional additions. Review the compiled change, then capture and validate the new baseline with the same deterministic context used earlier:

"$HOME/.azure/bin/bicep" snapshot cloudtrips-bicep/environments/test.bicepparam \
  --mode overwrite \
  --subscription-id 00000000-0000-0000-0000-000000000000 \
  --resource-group rg-cloudtrips-bicep-test-weu \
  --location westeurope \
  --deployment-name deploy-cloudtrips-storage-test

"$HOME/.azure/bin/bicep" snapshot cloudtrips-bicep/environments/test.bicepparam \
  --mode validate \
  --subscription-id 00000000-0000-0000-0000-000000000000 \
  --resource-group rg-cloudtrips-bicep-test-weu \
  --location westeurope \
  --deployment-name deploy-cloudtrips-storage-test

Preview the Complete TEST Environment

Select the TEST subscription and run Azure preflight validation:

az account set \
  --subscription "<CloudTrips TEST subscription name or ID>"

az deployment sub validate \
  --name validate-cloudtrips-complete-test \
  --location westeurope \
  --parameters cloudtrips-bicep/environments/test.subscription.bicepparam \
  --validation-level Provider \
  --query "properties.provisioningState" \
  --output tsv

The result should be Succeeded. Now review the live change:

az deployment sub what-if \
  --name preview-cloudtrips-complete-test \
  --location westeurope \
  --parameters cloudtrips-bicep/environments/test.subscription.bicepparam \
  --validation-level Provider

The preview should create the Key Vault, Log Analytics workspace, Application Insights resource, and their module deployment records. It must not recreate the existing storage account or containers. Previously identified provider defaults can still appear as Modify noise.

Subscription what-if showing the new CloudTrips Key Vault and monitoring resources without replacing storage

Deploy and Verify the Environment

Deploy the complete TEST foundation:

az deployment sub create \
  --name deploy-cloudtrips-complete-test \
  --location westeurope \
  --parameters cloudtrips-bicep/environments/test.subscription.bicepparam \
  --query "properties.outputs.environmentSummary.value" \
  --output yaml

The summary should contain the TEST resource group and deterministic names for the storage account, Key Vault, Log Analytics workspace, and Application Insights.

Successful complete CloudTrips TEST deployment with the environment summary outputs

List the deployed top-level resources:

az resource list \
  --resource-group rg-cloudtrips-bicep-test-weu \
  --query "[].{name:name,type:type,location:location}" \
  --output table

Confirm that the resource group contains the storage account, Key Vault, Log Analytics workspace, and Application Insights resource. Open the resource group in the Azure portal and verify that the common tags have the expected TEST, owner, and cost-center values.

CloudTrips TEST resource group containing storage, Key Vault, Log Analytics, and Application Insights with common tags

Azure portal showing the completed CloudTrips TEST environment and its deployed resources

Prove That the Deployment Is Repeatable

Run the same subscription what-if command again after deployment. There must be no new resource names, replacements, or unexpected deletions. Azure provider defaults can still appear as Modify, but the four top-level resources must remain the same.

The result is repeatable because environment differences live in parameter files, naming is deterministic, services are isolated in modules, dependencies come from symbolic references, and the subscription entry point owns the scope transition. CloudTrips can now review and recreate the same application foundation consistently instead of rebuilding it manually.