Bicep in der Praxis: Module, Teamstandards, Governance und Deployment für Entwicklerteams

12 Minuten Florian Bader
Von AVM über Naming bis What-If: Dieser Leitfaden erklärt, wie Bicep in echten Entwicklerteams sauber, sicher und wiederverwendbar eingesetzt wird.

Wer Azure-Infrastruktur nicht mehr per Portal, Copy-Paste oder schwer lesbarem Azure Resource Manager (ARM)-JSON pflegen will, landet früher oder später bei Bicep. Für Entwicklerteams ist das oft der pragmatischste Weg zu Infrastructure as Code auf Azure: Nah an ARM, deutlich lesbarer als JSON und stark genug für wiederverwendbare Standards.

Spannend wird es aber erst dann, wenn aus einem einzelnen Template ein Teamstandard wird. Dann geht es nicht mehr nur darum, ob ein Deployment funktioniert, sondern ob Module wiederverwendbar sind, rollenbasierte Zugriffssteuerung (RBAC) lesbar bleibt, Reviews sauber laufen und Governance nicht erst in Produktion auffällt. Genau darum geht es in diesem Artikel.

Warum Bicep für Entwickler eine geeignete Wahl ist

Bicep ist für viele Teams der pragmatischste Einstieg in Infrastructure as Code auf Azure, weil es das Azure-Deployment-Modell nicht versteckt, sondern lesbar macht. Du arbeitest weiter auf ARM-Basis, bekommst aber eine deutlich angenehmere Syntax, Typinformationen, Module, Funktionen und eine gute Tooling-Unterstützung in VS Code und CI. Gerade für Entwickler ist das oft der Punkt, an dem Infrastruktur nicht mehr wie ein Fremdsystem wirkt.

Der zweite Vorteil ist die Nähe zum Zielsystem. Terraform ist bewusst plattformübergreifend, Bicep dagegen sehr Azure-nah. Wenn dein Fokus klar auf Azure liegt, ist das meist ein Vorteil und kein Nachteil. Neue Resource Provider, Properties und Deployment-Funktionen lassen sich in ARM und Bicep meist direkt nutzen, ohne auf eine zusätzliche Abstraktionsschicht zu warten.

Trotzdem solltest du Bicep nicht wie eine allgemeine Programmiersprache behandeln. Zu viel Logik, verschachtelte Bedingungen und riesige Objektparameter machen Templates schnell unlesbar. Bicep ist dann stark, wenn du deklarativ bleibst und wiederkehrende Muster sauber kapselst.

Module sinnvoll schneiden

Sobald mehr als eine Resource Group oder mehr als ein Team beteiligt ist, brauchst du Module. Lokale Module sind ideal für projektspezifische Bausteine, zum Beispiel für einen App Service mit zugehöriger Managed Identity, Diagnostik und RBAC. Sie können für eine einzelne Anwendung mehrere Ressourcen in einem Modul bündeln oder über mehrere Apps hinweg als standardisierte Vorlage dienen. Ein lokales Modul liegt mit im Source Repository. Dadurch ist die Wiederverwendenbarkeit über mehrere Repositories und auch mehrere Teams hinweg schwierig.

Module in eine eigene Private Registry lohnen sich, wenn mehrere Repositories dieselben Standards nutzen sollen. Dazu kann eine Azure Container Registry erstellt werden, in der ihr eure eigenen Module versioniert und pflegt. Das ist besonders sinnvoll, wenn viele Teams dieselben Standards für Tags, Diagnostic Settings oder Security Defaults übernehmen sollen. Das Einpflegen geht hier sehr einfach, da die Module einfach direkt per Azure CLI gepusht werden können:

az bicep publish --file storage.bicep --target br:lunaris.azurecr.io/bicep/modules/storage:v1 --documentationUri https://www.contoso.com/exampleregistry.html --with-source

Azure Verified Modules, kurz AVM, sind davon getrennt zu betrachten. Sie kommen aus der öffentlichen Bicep Registry und sind eine gute Basis für Standardressourcen, weil sie gepflegte Defaults, saubere Parameter und eine breite Abdeckung mitbringen. Private Registries bringen dagegen operativen Aufwand mit: Build Agents und lokale Entwicklerumgebungen brauchen Zugriff, Versionen müssen gepflegt werden, und eine gestörte Registry blockiert im Zweifel das Deployment.

AVM solltest du trotzdem nicht unreflektiert überall direkt konsumieren. Wenn dein Team überall dieselben Tags, Diagnostic Settings, Naming-Regeln, Locks oder Security Defaults verlangt, lohnt sich meist ein dünner Wrapper um das jeweilige AVM-Modul. Der Wrapper reduziert die Parameterfläche für Fachteams und hält eure Standards an einer Stelle. Direkt auf ein AVM-Modul zu gehen ist dagegen sinnvoll, wenn du etwas nur einmal brauchst oder noch in einer frühen Prototypphase bist.

Eigene Standardmodule referenzierst du typischerweise aus eurer privaten Registry, AVM-Module aus der öffentlichen Bicep Registry über Pfade wie br/public:avm/...:x.y.z. Wichtig ist weniger die Quelle als die Disziplin dahinter. Versionen sollten fest gepinnt werden, damit ein Deployment in drei Monaten noch dasselbe Verhalten zeigt wie heute.

import { constructResourceName, mergeTags } from './shared/functions.bicep'

module storage 'modules/storage-account.bicep' = {
  name: 'storage-${environment}'
  params: {
    name: constructResourceName('storage', workload, environment, location)
    location: location
    tags: mergeTags([
      {
        application: workload
        component: 'api'
      }
    ])
    enableDiagnostics: true
  }
}

Dieses Muster wirkt unspektakulär, ist in der Praxis aber entscheidend. Die aufrufende Datei bleibt lesbar, während projektspezifische Standards im Modul landen. Wenn dieses Modul intern ein AVM-Storage-Modul aufruft, merkt das aufrufende Team davon idealerweise nur noch an der bewusst kleinen Parameteroberfläche etwas.

Namen, Tags und Helper zentralisieren

Ein häufiger Fehler in Bicep-Repositories ist verteilte String-Interpolation. Jedes Modul baut Namen, Tags und Standardwerte ein bisschen anders zusammen. Das funktioniert am Anfang, erzeugt später aber genau die Inkonsistenz, die Infrastructure as Code eigentlich verhindern soll.

Deshalb gehören Naming-Regeln und andere kleine Helper in ein zentrales Shared-Modul. Exportierte Funktionen in Shared-Dateien sind praktisch, setzen aber eine aktuelle Bicep-Toolchain in CLI, VS Code und Build Agents voraus. Prüfe das früh, sonst scheitert die Vereinheitlichung an unterschiedlichen Toolständen. Wenn du das Thema vertiefen willst, schau in unseren Artikel zu Azure Naming Conventions. Wichtig ist, dass du nicht nur Ressourcennamen vereinheitlichst, sondern auch Kürzel für Ressourcentypen und Regionen zentral pflegst. Gerade dafür lohnt sich ein generischer Namens-Helper, der Abkürzungen und Location-Codes aus JSON-Dateien lädt und daraus konsistente Namen baut. Dasselbe gilt für Tags: allgemeine Plattform-Tags definierst du an einer Stelle, projektspezifische Tags übergibst du als Liste und führst beides per union(...) zusammen.

var resources = loadJsonContent('resources.json')
var locations = loadJsonContent('locations.json')

@export()
func constructResourceName(resourceType string, applicationName string, environment string, location string) string =>
  !contains(resources.resourcesWithoutDash, resourceType)
    ? '${resources.prefixes[resourceType]}-${applicationName}-${environment}-${locations[location]}'
    : '${resources.prefixes[resourceType]}${applicationName}${environment}${locations[location]}'

var defaultTags = {
  managedBy: 'bicep'
  source: 'iac'
};

@export()
func mergeTags(customTags object) object => union(customTags, defaultTags)
import { constructResourceName, mergeTags } from './shared/functions.bicep'

resource app 'Microsoft.Web/sites@2024-04-01' = {
  name: constructResourceName('appService', workload, environment, location)
  location: location
  tags: mergeTags({
    environment: environment
    workload: workload
  })
}

Der Nutzen liegt nicht nur in schöneren Namen. Du schaffst damit eine zentrale Stelle für Änderungen. Wenn sich eure Resource-Abkürzungen, Location-Codes oder Tagging-Regeln ändern, passt du JSON-Dateien oder eine Hilfsfunktion an und nicht zwanzig Templates.

Konsistenz automatisieren

Sobald ein Team anfängt, in Pull Requests über Einrückung, Property-Reihenfolge oder API-Versionen zu diskutieren, fehlt meist nicht Disziplin, sondern Automation. Bicep bringt mit bicep format, dem Linter und bicepconfig.json bereits viel mit. Diese Werkzeuge solltest du nicht als nette Ergänzung sehen, sondern als festen Teil eures Delivery-Prozesses.

Eine gemeinsame bicepconfig.json ist dabei vor allem der Ort, an dem ihr eure Regeln einmal zentral festlegt. Dort konfigurierst du Analyzer-Regeln, Schweregrade und bei Bedarf auch Modul-Aliasse für private Registries. Damit verhält sich jedes Projekt konsistenter, selbst wenn mehrere Repositories beteiligt sind.

{
  "analyzers": {
    "core": {
      "enabled": true,
      "rules": {
        "use-recent-api-versions": { "level": "warning" },
        "no-unused-params": { "level": "error" }
      }
    }
  },
  "moduleAliases": {
    "br": {
      "Lunaris": {
        "registry": "lunaris.azurecr.io"
      }
    }
  }
}

In CI sollte der Weg klar sein. Formatierung prüfen, Linting ausführen, externe Module auflösen und danach What-If laufen lassen. Wenn möglich, ziehst du das lokal noch weiter nach vorne, zum Beispiel beim Speichern oder als Pre-Commit-Hook. Konsistenz entsteht nicht durch ein PDF im Wiki, sondern durch schnelle Rückmeldung direkt im Alltag.

bicep format --file main.bicep
bicep lint --file main.bicep
az deployment group validate --resource-group rg-demo --template-file main.bicep --parameters environment=dev
az deployment group what-if --resource-group rg-demo --template-file main.bicep --parameters environment=dev

RBAC und Managed Identity lesbar halten

Role Assignments sind in vielen Bicep-Repositories der Punkt, an dem Lesbarkeit abrupt endet. Überall stehen rohe GUIDs für Rollen, Scope-Logik ist dupliziert und die Benennung der Role Assignments ist mal stabil und mal zufällig. Spätestens dann wird aus Infrastructure as Code wieder Infrastruktur-Rätselraten.

Die bessere Variante ist ein eigenes RBAC-Modul mit klarer Parameteroberfläche. Das Mapping von lesbaren Rollennamen auf Role Definition IDs liegt dann im Modul und nicht verstreut in mehreren Deployments. Für die Ressourcennamen selbst solltest du deterministische guid(...)-Werte verwenden, typischerweise aus Scope, Principal und Rolle. So bleiben Deployments idempotent und produzieren nicht bei jedem Lauf neue Role Assignments.

@description('The name of the Key Vault')
param keyVaultName string

@description('The ID of the principal to assign the role to')
param principalId string = ''

@description('The type of principal to assign the role to')
@allowed([
  'Device'
  'ForeignGroup'
  'Group'
  'ServicePrincipal'
  'User'
  ''
])
param principalType string = ''

@allowed([
  'Key Vault Secrets User'
])
param roleDefinitions string[]

var roles = {
  'Key Vault Secrets User': '4633458b-17de-408a-b874-0445c86b69e6'
}

resource keyVault 'Microsoft.KeyVault/vaults@2021-11-01-preview' existing = {
  name: keyVaultName
}

resource roleAuthorization 'Microsoft.Authorization/roleAssignments@2022-04-01' = [
  for roleDefinition in roleDefinitions: {
    name: guid('keyvault-rbac', keyVault.id, resourceGroup().id, principalId, roles[roleDefinition])
    scope: keyVault
    properties: {
      principalId: principalId
      roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roles[roleDefinition])
      principalType: empty(principalType) ? null : principalType
    }
  }
]

So ein Modul rufst du dann von einer Fachressource aus gezielt auf. Für einen Storage Account kann das zum Beispiel so aussehen:

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = {
  name: storageAccountName
}

module storageRbac 'modules/role-assignment.storage.bicep' = {
  name: 'storage-rbac-${storageAccount.name}'
  params: {
    storageAccountName: storageAccount.name
    principalId: functionAppIdentityPrincipalId
    principalType: 'ServicePrincipal'
    roleDefinitions: [
      'Storage Blob Data Reader'
    ]
  }
}

Inhaltlich gehört RBAC fast immer mit Managed Identity zusammen. Wenn du Rollen sauber in Modulen kapselst, solltest du auch Secrets konsequent vermeiden und Identitäten statt Passwörtern einsetzen. Wenn du das Thema vertiefen willst, schau in unseren Artikel zu Managed Identity in Azure. Die wichtigste Regel bleibt dabei schlicht, gib jeder App nur die Rechte, die sie wirklich braucht, und idealerweise auf dem kleinstmöglichen Scope.

Das Beispiel geht von einer Managed Identity aus. Wenn du Benutzer oder Gruppen berechtigst, musst du principalType entsprechend anpassen. Und noch ein Praxispunkt: Neue Identitäten sind in Microsoft Entra ID nicht immer sofort auflösbar. Trotz korrekter Abhängigkeiten schlagen Role Assignments deshalb direkt nach der Erstellung gelegentlich fehl. In solchen Fällen helfen ein separater RBAC-Schritt oder ein erneuter Pipeline-Lauf.

What-If als Review-Hilfe nutzen

Bicep wird im Alltag deutlich wertvoller, wenn du nicht nur deployst, sondern Änderungen vorher sichtbar machst. what-if ist dafür eines der nützlichsten Werkzeuge in Azure Resource Manager. Du erkennst vor dem eigentlichen Deployment, welche Ressourcen erstellt, geändert oder gelöscht würden und ob eine Änderung vielleicht sogar einen Replace auslöst. Gerade in Pull Requests macht das einen großen Unterschied.

az deployment sub what-if \
  --location westeurope \
  --template-file main.bicep \
  --parameters env=prod

Auf Subscription- oder Management-Group-Scope kann What-If bei großen Umgebungen spürbar dauern und braucht dieselben Berechtigungen wie das spätere Deployment.

Bei kleinen Änderungen reicht die rohe CLI-Ausgabe oft aus. Bei größeren Templates wird sie schnell unübersichtlich. Genau dafür haben wir den Lunaris What-If Visualizer auf GitHub gebaut. Das Tool bereitet What-If-Ergebnisse verständlicher auf und ist auch als NuGet-Paket Lunaris.WhatIfVisualizer verfügbar.

Trotzdem bleibt What-If eine Review-Hilfe und keine Garantiezusage. Provider-seitige Defaults, bestimmte Laufzeiteffekte, Policy-Auswirkungen oder nachgelagerte Data-Plane-Änderungen sieht das Tool nur eingeschränkt oder gar nicht. Auch bestehende Ressourcen außerhalb des Templates oder nicht deterministische Werte können das Bild verfälschen. Nutze What-If deshalb als starke Vorschau, aber nicht als Ersatz für Architekturverständnis und saubere Reviews.

Deployment Stacks als Ergänzung für Lifecycle und Schutz

Ein wichtiger Punkt im Alltag ist, dass Bicep standardmäßig inkrementell arbeitet. Ein Deployment aktualisiert also Ressourcen, die im Template enthalten sind, löscht aber keine Ressourcen, die das Template nicht mehr kennt. Das ist oft sinnvoll, kann aber bei Refactorings oder aufgeräumten Modulen dazu führen, dass alte Infrastruktur einfach liegen bleibt.

Genau hier sind Deployment Stacks interessant. Du kannst die Ressourcen eines Bicep-Deployments einem Stack zuordnen und darüber festlegen, was passieren soll, wenn Ressourcen aus dem Template verschwinden. Damit wird Bicep nicht automatisch destruktiv, aber du bekommst einen kontrollierten Mechanismus für Drift-Bereinigung und geordnetes Aufräumen.

Zusätzlich bieten Deployment Stacks eigene Delete- und Update-Locks. Das sind keine klassischen Azure Resource Locks auf der Ressource selbst, sondern Schutzmechanismen auf Ebene des Stacks. Sie helfen dabei, versehentliche Änderungen oder Löschungen an Bicep-verwalteten Ressourcen außerhalb des vorgesehenen Deployments zu verhindern. So müssen auch nicht umständlich die Resource Locks vor einem Deployment entfernt und danach wieder angelegt werden. Der Deployment Stack sorgt automatisch dafür, dass die richtigen Schutzregeln immer an der richtigen Stelle liegen.

Ein What-If geht bei einem Deployment Stack aktuell noch nicht. Dieser kann zwar wie gewohnt verwendet werden, zeigt aber nicht, ob eine Ressource z.B. vom Stack gelöscht werden würde.

Deployment Stacks zu definieren geht sehr einfach. Hierzu wird einfach der Deployment-Befehl geändert:

az stack group create \
  --name "name" \
  --resource-group "rg-example" \
  --action-on-unmanage deleteResources \
  --deny-settings-mode denyDelete \
  --parameters "dev.bicepparams"
  main.bicep

Für Teams ist das vor allem dann nützlich, wenn Bicep nicht nur erstellt, sondern auch den vollständigen Lebenszyklus einer Umgebung abbilden soll. What-If zeigt dir, was sich ändern würde. Deployment Stacks helfen dir zusätzlich dabei, Besitz, Aufräumverhalten und Schutzregeln für genau diese Ressourcen sauber zu definieren.

Mehr Standards für Betrieb und Governance

Viele Probleme in Bicep entstehen nicht in den Resource-Definitionen selbst, sondern an den Rändern. Zu viele Parameter machen Module schwer nutzbar, zu viele Outputs koppeln Stages unnötig, und riesige Objektparameter verstecken eigentlich fehlende Modulgrenzen. Ein gutes Modul hat eine kleine, verständliche Oberfläche und sinnvolle Defaults.

Ebenso wichtig ist der bewusste Umgang mit bestehenden Ressourcen. Ein Shared Log Analytics Workspace, ein zentrales VNet oder ein vorhandener Key Vault sollten in Bicep klar als existing modelliert werden, statt still als String herumgereicht zu werden. Für sensible Werte gilt, markiere sie mit @secure(), aber noch besser ist es, sie möglichst gar nicht erst als Parameter zu benötigen. Managed Identity, Authentifizierung über Microsoft Entra ID und saubere Service-Verbindungen sind hier fast immer die robustere Lösung.

Governance und Kosten solltest du nicht erst ganz am Ende mitdenken. Tags, Diagnostic Settings, Policy Assignments und Mindeststandards für Regionen oder SKU-Klassen gehören möglichst früh in Module und Plattformvorgaben. Wenn du den Kostenblick schärfen willst, schau in unseren Artikel zu Azure Kostenoptimierung. Je früher Infrastrukturstandards auch Betriebs- und Kostenthemen abdecken, desto weniger Überraschungen produziert ein wachsendes Azure-Setup.

Pragmatisch starten und dann schärfer werden

Du musst nicht mit einer perfekten Plattformbibliothek anfangen. Ein solides main.bicep, ein Shared-Modul für Naming und Tags, zwei oder drei saubere Fachmodule und eine CI-Strecke mit Format, Lint, Restore und What-If bringen viele Teams schon deutlich weiter. Danach kannst du gezielt erweitern, etwa mit AVM-Wrappern, Registry-Modulen und zentralen RBAC-Bausteinen.

Wichtig ist nicht, ob euer erstes Bicep-Repository schon jede Sonderregel kennt. Wichtig ist, Copy-Paste zu reduzieren, Lesbarkeit ernst zu nehmen und eure Infrastruktur mit denselben Qualitätsmaßstäben zu behandeln wie euren Anwendungscode. Dann werden Standards nicht zur Bürokratie, sondern zur Entlastung im Team.

Wie organisiert ihr heute eure Bicep-Module, Role Assignments und What-If-Reviews im Team? Schau gerne bei uns auf LinkedIn vorbei und diskutiere mit.

Autoren

Florian Bader

Florian Bader

Florian ist Solution Architect und Microsoft Most Valuable Professional (MVP) für Azure IoT und DevOps mit langjähriger Erfahrung im Bereich DevOps, Cloud und Digitalisierung. Er unterstützt Unternehmen dabei, effiziente und effektive Lösungen zu entwickeln, die ihre digitalen Projekte nachhaltig zum Erfolg führen.